Preguntas de entrevista Symfony: Top 25 en 2026
Las 25 preguntas de entrevista Symfony más frecuentes. Arquitectura, Doctrine ORM, servicios, seguridad, formularios y tests con respuestas detalladas y ejemplos de código.

Las entrevistas de Symfony evalúan el dominio del framework PHP profesional de referencia, la comprensión de la arquitectura orientada a componentes, el ORM Doctrine y la capacidad de construir aplicaciones robustas y escalables. Esta guía cubre las 25 preguntas más frecuentes, desde los fundamentos de Symfony hasta los patrones avanzados de producción.
Los reclutadores valoran a los candidatos que entienden la filosofía de Symfony: desacoplamiento mediante servicios, configuración explícita y cumplimiento de los estándares PSR. Explicar las decisiones arquitectónicas del framework marca la diferencia.
Fundamentos de Symfony
Pregunta 1: Explica el ciclo de vida de una petición en Symfony
El ciclo de vida de una petición Symfony atraviesa el HTTP Kernel y utiliza un sistema de eventos para permitir la extensión en cada paso. Comprenderlo es esencial para depurar y personalizar el comportamiento de la aplicación.
// Punto de entrada para todas las peticiones HTTP
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// Symfony Runtime gestiona el bootstrap
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// El Kernel orquesta el procesamiento de la petición
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
// El Kernel carga los bundles y configura el container
// Eventos clave del ciclo de vida:
// 1. kernel.request - Antes del routing
// 2. kernel.controller - Tras la resolución del controlador
// 3. kernel.view - Si el controlador no devuelve una Response
// 4. kernel.response - Antes de enviar la respuesta
// 5. kernel.terminate - Tras el envío (tareas asíncronas)
}El ciclo completo: index.php → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. Cada paso puede interceptarse mediante Event Subscribers.
Pregunta 2: ¿Qué es el Service Container y la inyección de dependencias en Symfony?
El Service Container (o DIC, Dependency Injection Container) es el corazón de Symfony. Gestiona la instanciación, configuración e inyección de todos los servicios de la aplicación.
// Servicio con dependencias inyectadas automáticamente
namespace App\Service;
use App\Repository\OrderRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaymentService
{
public function __construct(
private readonly HttpClientInterface $stripeClient, // Cliente HTTP configurado
private readonly OrderRepository $orderRepository, // Repositorio Doctrine
private readonly LoggerInterface $logger, // Logger PSR-3
private readonly string $stripeApiKey, // Parámetro inyectado
) {}
public function processPayment(int $orderId, float $amount): bool
{
$order = $this->orderRepository->find($orderId);
try {
$response = $this->stripeClient->request('POST', '/charges', [
'body' => [
'amount' => $amount * 100,
'currency' => 'eur',
'source' => $order->getPaymentToken(),
],
]);
$order->markAsPaid($response->toArray()['id']);
$this->orderRepository->save($order, true);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order' => $orderId,
'error' => $e->getMessage(),
]);
return false;
}
}
}# config/services.yaml
# Configuración de servicios
services:
_defaults:
autowire: true # Inyección automática por type-hint
autoconfigure: true # Configuración automática de tags
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Configuración explícita con parámetros
App\Service\PaymentService:
arguments:
$stripeClient: '@stripe.client'
$stripeApiKey: '%env(STRIPE_API_KEY)%'El autowiring resuelve automáticamente las dependencias por type-hint. Los parámetros escalares requieren configuración explícita.
Pregunta 3: ¿Cuál es la diferencia entre un Bundle y un componente Symfony?
Los Bundles son paquetes reutilizables que integran funcionalidades en una aplicación Symfony. Los componentes son librerías PHP independientes utilizables sin Symfony.
// Estructura de un Bundle personalizado
namespace App\MyBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class MyBundle extends AbstractBundle
{
// Carga la configuración del bundle
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
// Carga los servicios del bundle
$container->import('../config/services.yaml');
// Configuración condicional
if ($config['feature_enabled']) {
$container->services()
->set('my_bundle.feature_service', FeatureService::class)
->autowire();
}
}
// Configuración por defecto del bundle
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->booleanNode('feature_enabled')->defaultTrue()->end()
->scalarNode('api_key')->isRequired()->end()
->end();
}
}// Uso de un componente sin Symfony
// Los componentes son librerías PHP independientes
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// Utilizables en cualquier proyecto PHP
$request = Request::createFromGlobals();
$response = new Response('Hello World', 200);
$response->send();Los Bundles encapsulan configuración, servicios y recursos. Los componentes son herramientas de bajo nivel reutilizables en cualquier sitio.
Pregunta 4: ¿Cómo funcionan los Event Subscribers en Symfony?
Los Event Subscribers permiten reaccionar a eventos del framework o de la aplicación, desacoplando la lógica de negocio del código principal.
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiExceptionSubscriber implements EventSubscriberInterface
{
// Declara los eventos escuchados y su prioridad
public static function getSubscribedEvents(): array
{
return [
// Prioridad alta (ejecutado antes que otros)
KernelEvents::EXCEPTION => ['onKernelException', 100],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$request = $event->getRequest();
// Solo procesa peticiones API
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
$statusCode = $exception instanceof HttpExceptionInterface
? $exception->getStatusCode()
: 500;
$response = new JsonResponse([
'error' => true,
'message' => $exception->getMessage(),
'code' => $statusCode,
], $statusCode);
// Sustituye la respuesta por nuestro JSON
$event->setResponse($response);
}
public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
{
// Añade cabeceras personalizadas
$event->getResponse()->headers->set('X-Api-Version', '1.0');
}
}Los Event Subscribers se descubren automáticamente gracias a autoconfigure. La prioridad determina el orden de ejecución (mayor = ejecutado primero).
Doctrine ORM
Pregunta 5: Explica las relaciones de Doctrine y sus diferencias
Doctrine ofrece varios tipos de relaciones para modelar asociaciones entre entidades. Cada tipo tiene implicaciones para las consultas y el rendimiento.
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// Relación OneToOne: un usuario tiene un perfil
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
// Relación OneToMany: un usuario tiene varios artículos
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)]
private Collection $articles;
// Relación ManyToMany: varios usuarios tienen varios roles
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
#[ORM\JoinTable(name: 'user_roles')]
private Collection $roles;
public function __construct()
{
// Inicialización obligatoria de la colección
$this->articles = new ArrayCollection();
$this->roles = new ArrayCollection();
}
public function addArticle(Article $article): static
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
$article->setAuthor($this); // Sincronización bidireccional
}
return $this;
}
public function removeArticle(Article $article): static
{
if ($this->articles->removeElement($article)) {
if ($article->getAuthor() === $this) {
$article->setAuthor(null);
}
}
return $this;
}
}#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// Relación ManyToOne: varios artículos pertenecen a un autor
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
// ManyToMany con atributos adicionales mediante entidad pivote
#[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
private Collection $articleTags;
}Las relaciones bidireccionales requieren sincronización manual. El lado "owning" (con JoinColumn/JoinTable) controla la persistencia.
Pregunta 6: ¿Qué es el problema N+1 y cómo resolverlo con Doctrine?
El problema N+1 ocurre cuando una consulta principal genera N consultas adicionales para cargar las relaciones. Es la causa más común de lentitud en aplicaciones Symfony.
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// MAL: N+1 consultas si se accede a los autores
public function findAllBad(): array
{
return $this->findAll();
// + 1 consulta por artículo para cargar el autor
}
// BIEN: JOIN con eager fetch
public function findAllWithAuthor(): array
{
return $this->createQueryBuilder('a')
->addSelect('u') // SELECT también el autor
->leftJoin('a.author', 'u') // JOIN sobre la relación
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// BIEN: múltiples JOIN para múltiples relaciones
public function findAllWithDetails(): array
{
return $this->createQueryBuilder('a')
->addSelect('u', 'c', 't') // SELECT todas las relaciones
->leftJoin('a.author', 'u')
->leftJoin('a.comments', 'c')
->leftJoin('a.tags', 't')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// BIEN: carga por lotes para listas grandes
public function findAllOptimized(): array
{
$query = $this->createQueryBuilder('a')
->getQuery();
// Carga las relaciones por lotes de 100
$query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH);
return $query->getResult();
}
}El Symfony Profiler con el panel Doctrine permite detectar problemas N+1. El número de consultas aparece en la Web Debug Toolbar.
Pregunta 7: ¿Cómo crear Query Extensions y filtros Doctrine?
Las Query Extensions y los filtros Doctrine permiten aplicar condiciones automáticamente a todas las consultas, ideal para multi-tenancy o soft delete.
// Extensión API Platform para filtrar automáticamente por usuario
namespace App\Doctrine\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Article;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
// Solo se aplica a los Article
if ($resourceClass !== Article::class) {
return;
}
// El admin lo ve todo
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
$user = $this->security->getUser();
$rootAlias = $queryBuilder->getRootAliases()[0];
// Filtro automático por autor
$queryBuilder
->andWhere(sprintf('%s.author = :current_user', $rootAlias))
->setParameter('current_user', $user);
}
}// Filtro Doctrine global para excluir elementos eliminados
namespace App\Doctrine\Filter;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
// Verifica si la entidad tiene un campo deletedAt
if (!$targetEntity->hasField('deletedAt')) {
return '';
}
return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
}
}# config/packages/doctrine.yaml
doctrine:
orm:
filters:
soft_delete:
class: App\Doctrine\Filter\SoftDeleteFilter
enabled: trueLos filtros se aplican a nivel SQL, las extensions a nivel del QueryBuilder. Se desactivan temporalmente con $em->getFilters()->disable('soft_delete').
¿Listo para aprobar tus entrevistas de Symfony?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Seguridad en Symfony
Pregunta 8: ¿Cómo funciona el sistema de seguridad de Symfony?
El componente Security de Symfony gestiona la autenticación (quién es el usuario) y la autorización (qué puede hacer) mediante una arquitectura extensible.
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~ # Lexik JWT Bundle
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
- { path: ^/admin, roles: ROLE_ADMIN }// Autenticador personalizado para lógica específica
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// Este autenticador solo procesa peticiones con X-API-KEY
return $request->headers->has('X-API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-API-KEY');
if (null === $apiKey) {
throw new AuthenticationException('No API key provided');
}
// UserBadge carga al usuario por identificador
return new SelfValidatingPassport(
new UserBadge($apiKey, function (string $apiKey) {
// Lógica de carga de usuario por API key
return $this->userRepository->findByApiKey($apiKey);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// null = continuar la petición normalmente
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'error' => $exception->getMessage(),
], Response::HTTP_UNAUTHORIZED);
}
}La arquitectura de seguridad se basa en Firewalls (configuración), Authenticators (autenticación) y Voters (autorización).
Pregunta 9: ¿Cómo implementar Voters para una autorización fina?
Los Voters permiten una lógica de autorización compleja y reutilizable, separando las reglas de negocio del código del controlador.
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const VIEW = 'ARTICLE_VIEW';
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
// Este voter solo procesa Article y estos atributos
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
$article = $subject;
// Los artículos publicados son visibles para todos
if ($attribute === self::VIEW && $article->isPublished()) {
return true;
}
// Las demás acciones requieren un usuario autenticado
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW => $this->canView($article, $user),
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
default => false,
};
}
private function canView(Article $article, User $user): bool
{
// Borradores visibles solo para el autor o los administradores
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canEdit(Article $article, User $user): bool
{
// Solo el autor puede editar
return $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// El autor o un administrador puede eliminar
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
}// Uso del Voter en un controlador
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ArticleController extends AbstractController
{
#[Route('/articles/{id}/edit', name: 'article_edit')]
#[IsGranted(ArticleVoter::EDIT, subject: 'article')]
public function edit(Article $article): Response
{
// La autorización se verifica automáticamente
// 403 si el voter deniega el acceso
}
// Alternativa programática
#[Route('/articles/{id}', name: 'article_show')]
public function show(Article $article): Response
{
$this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article);
// O con condición
if (!$this->isGranted(ArticleVoter::EDIT, $article)) {
// Ocultar el botón de edición
}
}
}{# En Twig #}
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('article_edit', {id: article.id}) }}">Editar</a>
{% endif %}Los Voters se descubren automáticamente y se consultan al llamar a isGranted(). La estrategia por defecto concede el acceso si al menos un Voter vota positivamente.
Pregunta 10: ¿Cómo proteger una API con JWT en Symfony?
La autenticación JWT (JSON Web Token) es la solución estándar para APIs sin estado. Symfony utiliza típicamente el LexikJWTAuthenticationBundle.
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1 horanamespace App\Controller\Api;
use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api')]
class AuthController extends AbstractController
{
#[Route('/login', name: 'api_login', methods: ['POST'])]
public function login(
Request $request,
UserPasswordHasherInterface $passwordHasher,
JWTTokenManagerInterface $jwtManager,
): JsonResponse {
$data = json_decode($request->getContent(), true);
$user = $this->userRepository->findOneBy(['email' => $data['email']]);
if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'])) {
return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
// Generar token JWT
$token = $jwtManager->create($user);
return new JsonResponse([
'token' => $token,
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
],
]);
}
#[Route('/refresh-token', name: 'api_refresh_token', methods: ['POST'])]
public function refreshToken(): JsonResponse
{
// Gestionado automáticamente por el bundle si está configurado
// Devuelve un nuevo token a partir del refresh token
}
}// Personalización del payload JWT
namespace App\EventListener;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
class JWTCreatedListener
{
public function onJWTCreated(JWTCreatedEvent $event): void
{
$user = $event->getUser();
$payload = $event->getData();
// Añade datos personalizados al token
$payload['user_id'] = $user->getId();
$payload['email'] = $user->getEmail();
$payload['permissions'] = $user->getPermissions();
$event->setData($payload);
}
}El token JWT se envía en la cabecera Authorization: Bearer <token>. El bundle verifica automáticamente la firma y la expiración.
Formularios Symfony
Pregunta 11: ¿Cómo crear formularios avanzados con validación?
El componente Form de Symfony genera formularios HTML, gestiona el envío y valida los datos con restricciones.
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Título del artículo',
'attr' => ['placeholder' => 'Introduce el título...'],
'constraints' => [
new Assert\NotBlank(message: 'El título es obligatorio'),
new Assert\Length(
min: 10,
max: 255,
minMessage: 'El título debe tener al menos {{ limit }} caracteres',
),
],
])
->add('content', TextareaType::class, [
'label' => 'Contenido',
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 100),
],
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Elige una categoría',
'query_builder' => function ($repo) {
return $repo->createQueryBuilder('c')
->where('c.active = true')
->orderBy('c.name', 'ASC');
},
])
->add('coverImage', FileType::class, [
'label' => 'Imagen de portada',
'mapped' => false, // No vinculado a la entidad
'required' => false,
'constraints' => [
new Assert\Image(
maxSize: '5M',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
mimeTypesMessage: 'Formato de imagen no compatible',
),
],
])
->add('publishedAt', DateTimeType::class, [
'widget' => 'single_text',
'required' => false,
'label' => 'Fecha de publicación',
]);
// Event listener para modificar el formulario dinámicamente
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$article = $event->getData();
$form = $event->getForm();
// Añade el campo solo en modificación
if ($article && $article->getId()) {
$form->add('slug', TextType::class, [
'disabled' => true,
'help' => 'El slug no puede modificarse',
]);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
'validation_groups' => ['Default', 'article_creation'],
]);
}
}#[Route('/articles/new', name: 'article_new')]
public function new(Request $request, SluggerInterface $slugger): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Gestión de la subida de archivos
$coverImage = $form->get('coverImage')->getData();
if ($coverImage) {
$filename = $slugger->slug($article->getTitle()).'-'.uniqid().'.'.$coverImage->guessExtension();
$coverImage->move($this->getParameter('covers_directory'), $filename);
$article->setCoverImageFilename($filename);
}
$this->entityManager->persist($article);
$this->entityManager->flush();
$this->addFlash('success', '¡Artículo creado correctamente!');
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}Los FormEvents (PRE_SET_DATA, POST_SUBMIT, etc.) permiten modificar dinámicamente los campos según el contexto.
Pregunta 12: ¿Cómo implementar validación personalizada con restricciones?
Symfony permite crear restricciones de validación personalizadas para reglas de negocio complejas.
// Restricción personalizada
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
public string $message = 'El email "{{ value }}" ya está en uso.';
public ?int $excludeId = null; // Para excluir al usuario actual al actualizar
}// Validador asociado a la restricción
namespace App\Validator;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UniqueEmailValidator extends ConstraintValidator
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof UniqueEmail) {
throw new UnexpectedTypeException($constraint, UniqueEmail::class);
}
if (null === $value || '' === $value) {
return; // NotBlank gestiona los valores vacíos
}
$existingUser = $this->userRepository->findOneBy(['email' => $value]);
// Verifica si existe un usuario con este email
// y que no es el usuario actual (caso de actualización)
if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}// Uso de la restricción en la entidad
use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
class User
{
#[Assert\NotBlank]
#[Assert\Email]
#[AppAssert\UniqueEmail]
private ?string $email = null;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\Regex(
pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/',
message: 'La contraseña debe contener una mayúscula, una minúscula y un dígito'
)]
private ?string $plainPassword = null;
}// Restricción a nivel de clase para validación multi-campo
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
public string $message = 'Las contraseñas no coinciden.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}Las restricciones personalizadas se descubren automáticamente. El sufijo Validator es obligatorio para el validador.
Messenger y comunicación asíncrona
Pregunta 13: ¿Cómo implementar procesamiento asíncrono con Messenger?
Symfony Messenger envía mensajes a colas para un procesamiento asíncrono, mejorando la capacidad de respuesta de la aplicación.
// El mensaje (DTO con los datos)
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $locale = 'en',
) {}
}// El handler que procesa el mensaje
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return; // Usuario eliminado mientras tanto
}
$email = (new TemplatedEmail())
->to($user->getEmail())
->subject('¡Bienvenido a nuestra plataforma!')
->htmlTemplate('emails/welcome.html.twig')
->context([
'user' => $user,
'locale' => $message->locale,
]);
$this->mailer->send($email);
}
}# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# Enrutar mensajes al transporte async
App\Message\SendWelcomeEmail: async
App\Message\ProcessImage: async
App\Message\GenerateReport: async// Despacho del mensaje
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register', methods: ['POST'])]
public function register(
Request $request,
MessageBusInterface $bus,
): Response {
// ... creación del usuario
// Despacho asíncrono - el email se enviará en segundo plano
$bus->dispatch(new SendWelcomeEmail(
userId: $user->getId(),
locale: $request->getLocale(),
));
// Respuesta inmediata al usuario
return $this->redirectToRoute('app_login');
}
}El worker se inicia con php bin/console messenger:consume async -vv. En producción se utiliza Supervisor para mantener el worker activo.
Pregunta 14: ¿Cómo gestionar errores y reintentos con Messenger?
Messenger ofrece mecanismos robustos para manejar fallos: reintentos automáticos, dead letter queue y gestión manual de mensajes fallidos.
namespace App\MessageHandler;
use App\Exception\PaymentFailedException;
use App\Exception\PaymentRetryableException;
use App\Message\ProcessPayment;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
#[AsMessageHandler]
final class ProcessPaymentHandler
{
public function __invoke(ProcessPayment $message): void
{
try {
$this->paymentGateway->process($message->orderId);
} catch (PaymentRetryableException $e) {
// Error temporal (timeout, rate limit) → reintentar
throw new RecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
} catch (PaymentFailedException $e) {
// Error permanente (tarjeta inválida) → no reintentar
throw new UnrecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
}
}
}// Configuración del retry a nivel del mensaje
namespace App\Message;
use Symfony\Component\Messenger\Stamp\DelayStamp;
final class ProcessPayment
{
public function __construct(
public readonly int $orderId,
public readonly int $attempt = 1,
) {}
// Retraso de retry personalizado según el número de intentos
public function getRetryDelay(): int
{
return match ($this->attempt) {
1 => 5000, // 5 segundos
2 => 30000, // 30 segundos
3 => 300000, // 5 minutos
default => 600000,
};
}
}# Comandos de gestión de mensajes fallidos
php bin/console messenger:failed:show # Listar mensajes fallidos
php bin/console messenger:failed:retry # Reintentar todos los mensajes
php bin/console messenger:failed:retry 123 # Reintentar un mensaje específico
php bin/console messenger:failed:remove 123 # Eliminar un mensajeLa estrategia de retry y el transporte "failed" garantizan que ningún mensaje se pierda. Los mensajes pueden analizarse y reintentarse manualmente.
¿Listo para aprobar tus entrevistas de Symfony?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Tests en Symfony
Pregunta 15: ¿Cómo estructurar los tests en Symfony?
Symfony proporciona PHPUnit con helpers dedicados para probar las distintas capas de la aplicación: tests unitarios, funcionales y de integración.
// Test unitario: prueba una clase aislada
namespace App\Tests\Unit\Service;
use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
public function testCalculateTotalWithoutDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 0);
$this->assertEquals(100.00, $total);
}
public function testCalculateTotalWithPercentageDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 20);
$this->assertEquals(80.00, $total);
}
/**
* @dataProvider discountProvider
*/
public function testCalculateTotalWithVariousDiscounts(
float $price,
int $discount,
float $expected
): void {
$total = $this->calculator->calculateTotal($price, $discount);
$this->assertEquals($expected, $total);
}
public static function discountProvider(): array
{
return [
'no discount' => [100.00, 0, 100.00],
'10% discount' => [100.00, 10, 90.00],
'50% discount' => [200.00, 50, 100.00],
'max discount' => [100.00, 100, 0.00],
];
}
}// Test funcional: prueba los controladores vía HTTP
namespace App\Tests\Functional\Controller;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
private $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = static::createClient();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
}
public function testListArticlesReturnsOk(): void
{
$this->client->request('GET', '/articles');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('h1');
}
public function testCreateArticleRequiresAuthentication(): void
{
$this->client->request('GET', '/articles/new');
$this->assertResponseRedirects('/login');
}
public function testAuthenticatedUserCanCreateArticle(): void
{
// Autenticación
$user = $this->createUser();
$this->client->loginUser($user);
// Acceso al formulario
$crawler = $this->client->request('GET', '/articles/new');
$this->assertResponseIsSuccessful();
// Envío del formulario
$form = $crawler->selectButton('Crear')->form([
'article[title]' => 'Test Article Title',
'article[content]' => 'Este es el contenido de mi artículo de prueba con suficientes caracteres.',
]);
$this->client->submit($form);
// Verificación
$this->assertResponseRedirects();
$this->client->followRedirect();
$this->assertSelectorTextContains('h1', 'Test Article Title');
// Verificación en base de datos
$article = $this->entityManager->getRepository(Article::class)
->findOneBy(['title' => 'Test Article Title']);
$this->assertNotNull($article);
}
private function createUser(): User
{
$user = new User();
$user->setEmail('test@example.com');
$user->setPassword('$2y$13$hashedpassword');
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
protected function tearDown(): void
{
// Limpieza de la base de datos de prueba
$this->entityManager->getConnection()->executeStatement('DELETE FROM article');
$this->entityManager->getConnection()->executeStatement('DELETE FROM user');
parent::tearDown();
}
}Conviene separar tests unitarios (sin kernel), tests funcionales (con kernel) y tests de integración (servicios reales).
Pregunta 16: ¿Cómo usar fixtures y DatabaseResetter?
Las fixtures pueblan la base de datos con datos de prueba realistas. El componente DoctrineTestBundle facilita el reseteo entre tests.
namespace App\DataFixtures;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 20; $i++) {
$article = new Article();
$article->setTitle("Test Article Number $i");
$article->setSlug("test-article-$i");
$article->setContent("Contenido detallado del artículo número $i...");
$article->setStatus($i <= 15 ? 'published' : 'draft');
$article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null);
// Referencia al usuario creado por UserFixtures
$article->setAuthor($this->getReference('user-'.($i % 3), User::class));
$manager->persist($article);
// Crear referencias para otras fixtures
$this->addReference("article-$i", $article);
}
$manager->flush();
}
public function getDependencies(): array
{
// UserFixtures debe cargarse antes que ArticleFixtures
return [
UserFixtures::class,
];
}
}namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserFixtures extends Fixture implements FixtureGroupInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
$users = [
['email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN'], 'ref' => 'user-0'],
['email' => 'author@example.com', 'roles' => ['ROLE_AUTHOR'], 'ref' => 'user-1'],
['email' => 'user@example.com', 'roles' => ['ROLE_USER'], 'ref' => 'user-2'],
];
foreach ($users as $userData) {
$user = new User();
$user->setEmail($userData['email']);
$user->setRoles($userData['roles']);
$user->setPassword($this->passwordHasher->hashPassword($user, 'password123'));
$manager->persist($user);
$this->addReference($userData['ref'], $user);
}
$manager->flush();
}
public static function getGroups(): array
{
return ['test', 'dev'];
}
}// Uso de DAMADoctrineTestBundle para reseteo automático
namespace App\Tests\Functional;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
use \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
public function testPublishedArticlesCount(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$count = $em->getRepository(Article::class)
->count(['status' => 'published']);
$this->assertEquals(15, $count); // Según las fixtures
}
}# Carga de fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=testDAMADoctrineTestBundle envuelve cada test en una transacción que se revierte, evitando recargar las fixtures entre tests.
Arquitectura y patrones avanzados
Pregunta 17: ¿Cómo implementar CQRS con Symfony?
CQRS (Command Query Responsibility Segregation) separa las operaciones de lectura de las de escritura, permitiendo optimizarlas de forma independiente.
// Command: representa una intención de modificación
namespace App\Message\Command;
final class CreateArticleCommand
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly int $authorId,
public readonly array $tagIds = [],
) {}
}// CommandHandler: ejecuta la modificación
namespace App\MessageHandler\Command;
use App\Entity\Article;
use App\Message\Command\CreateArticleCommand;
use App\Repository\TagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\String\Slugger\SluggerInterface;
#[AsMessageHandler]
final class CreateArticleCommandHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly TagRepository $tagRepository,
private readonly SluggerInterface $slugger,
) {}
public function __invoke(CreateArticleCommand $command): Article
{
$author = $this->userRepository->find($command->authorId)
?? throw new \InvalidArgumentException('Author not found');
$article = new Article();
$article->setTitle($command->title);
$article->setSlug($this->slugger->slug($command->title)->lower());
$article->setContent($command->content);
$article->setAuthor($author);
foreach ($command->tagIds as $tagId) {
$tag = $this->tagRepository->find($tagId);
if ($tag) {
$article->addTag($tag);
}
}
$this->entityManager->persist($article);
$this->entityManager->flush();
return $article;
}
}// Query: representa una solicitud de lectura
namespace App\Message\Query;
final class GetArticleBySlugQuery
{
public function __construct(
public readonly string $slug,
public readonly bool $withComments = false,
) {}
}// QueryHandler: recupera los datos (puede usar un read model optimizado)
namespace App\MessageHandler\Query;
use App\DTO\ArticleDTO;
use App\Message\Query\GetArticleBySlugQuery;
use App\Repository\ArticleRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GetArticleBySlugQueryHandler
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function __invoke(GetArticleBySlugQuery $query): ?ArticleDTO
{
$qb = $this->repository->createQueryBuilder('a')
->select('a', 'u')
->leftJoin('a.author', 'u')
->where('a.slug = :slug')
->setParameter('slug', $query->slug);
if ($query->withComments) {
$qb->addSelect('c')
->leftJoin('a.comments', 'c');
}
$article = $qb->getQuery()->getOneOrNullResult();
return $article ? ArticleDTO::fromEntity($article) : null;
}
}// Uso de los Command y Query Bus
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
class ArticleController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly MessageBusInterface $queryBus,
) {}
#[Route('/articles', methods: ['POST'])]
public function create(Request $request): Response
{
$data = json_decode($request->getContent(), true);
$envelope = $this->commandBus->dispatch(new CreateArticleCommand(
title: $data['title'],
content: $data['content'],
authorId: $this->getUser()->getId(),
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article, 201);
}
#[Route('/articles/{slug}', methods: ['GET'])]
public function show(string $slug): Response
{
$envelope = $this->queryBus->dispatch(new GetArticleBySlugQuery(
slug: $slug,
withComments: true,
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article);
}
}CQRS permite optimizar lecturas (caching, proyecciones) y escrituras (validación, eventos) por separado.
Pregunta 18: ¿Cómo implementar el Repository Pattern correctamente en Symfony?
El Repository Pattern ya está presente en Symfony vía Doctrine, pero puede enriquecerse con interfaces y métodos de negocio.
// Interfaz para desacoplar y facilitar los tests
namespace App\Repository\Contract;
use App\Entity\Article;
use Doctrine\Common\Collections\Collection;
interface ArticleRepositoryInterface
{
public function find(int $id): ?Article;
public function findBySlug(string $slug): ?Article;
public function findPublished(int $limit = 20, int $offset = 0): array;
public function findByAuthor(int $authorId): array;
public function save(Article $article, bool $flush = false): void;
public function remove(Article $article, bool $flush = false): void;
}// Implementación Doctrine del repositorio
namespace App\Repository;
use App\Entity\Article;
use App\Repository\Contract\ArticleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository implements ArticleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
public function findBySlug(string $slug): ?Article
{
return $this->createQueryBuilder('a')
->addSelect('u', 't')
->leftJoin('a.author', 'u')
->leftJoin('a.tags', 't')
->where('a.slug = :slug')
->setParameter('slug', $slug)
->getQuery()
->getOneOrNullResult();
}
public function findPublished(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findByAuthor(int $authorId): array
{
return $this->createQueryBuilder('a')
->where('a.author = :authorId')
->setParameter('authorId', $authorId)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(Article $article, bool $flush = false): void
{
$this->getEntityManager()->persist($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Article $article, bool $flush = false): void
{
$this->getEntityManager()->remove($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
// Métodos de consultas complejas
public function findPopularByCategory(int $categoryId, int $limit = 5): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.category = :categoryId')
->andWhere('a.status = :status')
->setParameter('categoryId', $categoryId)
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}# config/services.yaml
# Vinculación de la interfaz a la implementación
services:
App\Repository\Contract\ArticleRepositoryInterface:
alias: App\Repository\ArticleRepositoryLa interfaz permite crear implementaciones de prueba (InMemoryArticleRepository) o cambiar la fuente de datos sin modificar el código de negocio.
Pregunta 19: ¿Cómo gestionar la configuración y los entornos en Symfony?
Symfony utiliza un sistema de configuración flexible con soporte para variables de entorno, secrets y archivos YAML por entorno.
# config/packages/framework.yaml
# Configuración por defecto
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
php_errors:
log: true# config/packages/prod/framework.yaml
# Override para producción
framework:
session:
handler_id: '%env(REDIS_URL)%'
cookie_secure: true
when@prod:
framework:
router:
strict_requirements: null// Gestión de secrets sensibles (cifrados)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod// Configuración personalizada para un bundle
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('my_app');
$treeBuilder->getRootNode()
->children()
->scalarNode('api_key')
->isRequired()
->cannotBeEmpty()
->end()
->integerNode('cache_ttl')
->defaultValue(3600)
->min(0)
->end()
->arrayNode('features')
->addDefaultsIfNotSet()
->children()
->booleanNode('dark_mode')->defaultTrue()->end()
->booleanNode('beta_features')->defaultFalse()->end()
->end()
->end()
->end();
return $treeBuilder;
}
}// Inyección de configuración
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ConfigurableService
{
public function __construct(
#[Autowire('%env(API_KEY)%')]
private readonly string $apiKey,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
#[Autowire('%my_app.cache_ttl%')]
private readonly int $cacheTtl,
) {}
}Los secrets de Symfony están cifrados y versionados. Conviene usar %env(...)% para variables en runtime y parámetros para valores estáticos.
Rendimiento y producción
Pregunta 20: ¿Cómo optimizar el rendimiento de una aplicación Symfony?
La optimización abarca varios niveles: opcache, configuración, caché de aplicación y consultas Doctrine.
# Configuración Doctrine optimizada para producción
doctrine:
orm:
auto_generate_proxy_classes: false
metadata_cache_driver:
type: pool
pool: doctrine.system_cache_pool
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool# config/packages/cache.yaml
# Configuración de caché con Redis
framework:
cache:
app: cache.adapter.redis
system: cache.adapter.system
pools:
doctrine.result_cache_pool:
adapter: cache.adapter.redis
default_lifetime: 3600
doctrine.system_cache_pool:
adapter: cache.adapter.system
services:
Redis:
class: Redis
calls:
- connect:
- '%env(REDIS_HOST)%'
- '%env(int:REDIS_PORT)%'
Symfony\Component\Cache\Adapter\RedisAdapter:
arguments:
- '@Redis'// Uso de la caché de resultados Doctrine
public function findPopularCached(): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->enableResultCache(3600, 'popular_articles') // Cache 1h
->getResult();
}# Comandos de optimización para producción
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console doctrine:cache:clear-metadata --env=prod
php bin/console doctrine:cache:clear-query --env=prod
# Generar los proxies de Doctrine
php bin/console doctrine:proxy:create-proxy-classes
# Compilar el autoloader optimizado
composer install --no-dev --optimize-autoloader --classmap-authoritativeOPcache debe estar habilitado en producción con la configuración óptima. El warmup genera la caché del container y del router.
Pregunta 21: ¿Cómo configurar logging y monitorización en Symfony?
Un logging estructurado y una monitorización adecuada son esenciales para diagnosticar problemas en producción.
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ['!event', '!doctrine']
slack:
type: slack
token: '%env(SLACK_TOKEN)%'
channel: '#alerts'
level: critical
bot_name: 'SymfonyBot'// Logging estructurado con contexto
namespace App\Service;
use Psr\Log\LoggerInterface;
class PaymentService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function processPayment(Order $order): bool
{
$this->logger->info('Payment processing started', [
'order_id' => $order->getId(),
'amount' => $order->getTotal(),
'currency' => $order->getCurrency(),
'user_id' => $order->getUser()->getId(),
]);
try {
$result = $this->gateway->charge($order);
$this->logger->info('Payment successful', [
'order_id' => $order->getId(),
'transaction_id' => $result->getTransactionId(),
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage(),
'exception' => $e,
]);
return false;
}
}
}// Logging de peticiones HTTP
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class RequestLoggerSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::TERMINATE => 'onKernelTerminate',
];
}
public function onKernelTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
$this->logger->info('Request completed', [
'method' => $request->getMethod(),
'uri' => $request->getRequestUri(),
'status' => $response->getStatusCode(),
'duration_ms' => round((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
]);
}
}El formato JSON facilita la ingesta por herramientas de monitorización (ELK, Datadog). Los canales permiten filtrar por tipo de log.
Pregunta 22: ¿Cómo desplegar una aplicación Symfony en producción?
Un despliegue Symfony robusto combina preparación del build, migraciones seguras y conmutación atómica.
#!/bin/bash
# deploy.sh - Script de despliegue
set -e
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/shared"
CURRENT_LINK="/var/www/current"
echo "Creando el directorio de release..."
mkdir -p $RELEASE_DIR
echo "Clonando el repositorio..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR
echo "Instalando dependencias..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --classmap-authoritative
echo "Enlazando archivos compartidos..."
ln -sf $SHARED_DIR/.env.local $RELEASE_DIR/.env.local
ln -sf $SHARED_DIR/var/log $RELEASE_DIR/var/log
ln -sf $SHARED_DIR/public/uploads $RELEASE_DIR/public/uploads
echo "Ejecutando migraciones..."
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
echo "Calentando la caché..."
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug
echo "Aplicando permisos..."
chown -R www-data:www-data $RELEASE_DIR
echo "Conmutando a la nueva release..."
ln -sfn $RELEASE_DIR $CURRENT_LINK
echo "Reiniciando PHP-FPM..."
sudo systemctl reload php8.3-fpm
echo "Reiniciando los workers Messenger..."
php bin/console messenger:stop-workers
echo "Limpiando releases antiguas..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf
echo "¡Despliegue completado!"# .github/workflows/deploy.yml
# Despliegue CI/CD con GitHub Actions
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_pgsql, redis
- name: Install dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run tests
run: php bin/phpunit
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/app
./deploy.shEl despliegue atómico mediante symlinks permite un rollback instantáneo. Los workers Messenger deben reiniciarse para cargar el nuevo código.
API Platform y REST
Pregunta 23: ¿Cómo construir una API REST con API Platform?
API Platform es la solución estándar para crear APIs REST y GraphQL con Symfony, ofreciendo auto-documentación y estándares HTTP.
// Configuración del recurso API Platform
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['article:list']],
),
new Get(
normalizationContext: ['groups' => ['article:read']],
),
new Post(
security: "is_granted('ROLE_AUTHOR')",
denormalizationContext: ['groups' => ['article:write']],
),
new Put(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Patch(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Delete(
security: "is_granted('ARTICLE_DELETE', object)",
),
],
order: ['publishedAt' => 'DESC'],
paginationItemsPerPage: 20,
)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['article:list', 'article:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 10, max: 255)]
#[Groups(['article:list', 'article:read', 'article:write'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Groups(['article:read', 'article:write'])]
private ?string $content = null;
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['article:list', 'article:read'])]
private ?User $author = null;
#[ORM\Column(nullable: true)]
#[Groups(['article:list', 'article:read'])]
private ?\DateTimeImmutable $publishedAt = null;
}// Processor personalizado para lógica de negocio
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\String\Slugger\SluggerInterface;
final class ArticleProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $persistProcessor,
private readonly Security $security,
private readonly SluggerInterface $slugger,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): Article {
if ($data instanceof Article && !$data->getId()) {
// Nuevo artículo: definir autor y slug
$data->setAuthor($this->security->getUser());
$data->setSlug($this->slugger->slug($data->getTitle())->lower());
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}# config/packages/api_platform.yaml
api_platform:
title: 'My API'
version: '1.0.0'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
defaults:
pagination_items_per_page: 20
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
swagger:
versions: [3]API Platform genera automáticamente la documentación OpenAPI y proporciona filtros, paginación y validación.
Pregunta 24: ¿Cómo personalizar las operaciones de API Platform?
API Platform permite crear operaciones personalizadas con controladores o State Providers/Processors.
// Operaciones personalizadas
#[ApiResource(
operations: [
// Operaciones estándar
new GetCollection(),
new Get(),
// Operación personalizada con controlador
new Post(
uriTemplate: '/articles/{id}/publish',
controller: PublishArticleController::class,
openapi: new Model\Operation(
summary: 'Publica un artículo',
description: 'Cambia el estado del artículo a "published"',
),
security: "is_granted('ARTICLE_EDIT', object)",
),
// Operación con State Provider personalizado
new GetCollection(
uriTemplate: '/articles/trending',
provider: TrendingArticlesProvider::class,
openapiContext: ['summary' => 'Artículos en tendencia'],
),
],
)]
class Article
{
// ...
}// Controlador para una operación personalizada
namespace App\Controller;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\AsController;
#[AsController]
class PublishArticleController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(Article $article): Article
{
if ($article->getStatus() === 'published') {
throw $this->createNotFoundException('Artículo ya publicado');
}
$article->setStatus('published');
$article->setPublishedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return $article;
}
}// State Provider para lógica de lectura personalizada
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ArticleRepository;
final class TrendingArticlesProvider implements ProviderInterface
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// Lógica personalizada para artículos en tendencia
return $this->repository->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.publishedAt > :date')
->setParameter('status', 'published')
->setParameter('date', new \DateTimeImmutable('-7 days'))
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
}
}Los State Providers gestionan la lectura, los State Processors la escritura. Los controladores siguen disponibles para los casos complejos.
Pregunta 25: ¿Cómo gestionar las migraciones de base de datos en producción?
Las migraciones Doctrine deben diseñarse para ejecutarse sin downtime y permitir un rollback sencillo.
// Migración segura: añadir columna nullable
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260202100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add role column to users table (nullable first)';
}
public function up(Schema $schema): void
{
// Paso 1: añadir columna nullable
$this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL');
// Crear el índice CONCURRENTLY (PostgreSQL - no bloqueante)
$this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users (role)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_users_role');
$this->addSql('ALTER TABLE users DROP COLUMN role');
}
}// Migración de datos (separada)
final class Version20260202100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Populate role column with default value';
}
public function up(Schema $schema): void
{
// Migración por lotes para tablas grandes
$this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
}
public function down(Schema $schema): void
{
// No es necesario rollback para los datos
}
}// Migración final: pasar la columna a NOT NULL
final class Version20260202100002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make role column NOT NULL';
}
public function up(Schema $schema): void
{
// Verificación previa
$count = $this->connection->fetchOne('SELECT COUNT(*) FROM users WHERE role IS NULL');
if ($count > 0) {
throw new \RuntimeException("$count users still have NULL role");
}
$this->addSql('ALTER TABLE users ALTER COLUMN role SET NOT NULL');
$this->addSql("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ROLE_USER'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP NOT NULL');
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP DEFAULT');
}
}# Comandos de gestión de migraciones
php bin/console doctrine:migrations:status # Estado de las migraciones
php bin/console doctrine:migrations:migrate # Ejecutar las migraciones
php bin/console doctrine:migrations:migrate prev # Revertir la última
php bin/console doctrine:migrations:diff # Generar migración a partir del schema
php bin/console doctrine:migrations:execute --down # Rollback específicoLa estrategia expand-contract (3 despliegues) garantiza un despliegue zero-downtime: añadir nullable → migrar datos → añadir restricción.
Conclusión
Estas 25 preguntas cubren lo esencial de las entrevistas Symfony, desde los fundamentos del Service Container hasta los patrones avanzados de producción.
Lista de preparación:
- ✅ Service Container e inyección de dependencias
- ✅ Doctrine ORM: relaciones, consultas, filtros
- ✅ Seguridad: autenticación, voters, JWT
- ✅ Formularios y validación personalizada
- ✅ Messenger: procesamiento asíncrono y manejo de errores
- ✅ Tests: unitarios, funcionales, fixtures
- ✅ Arquitectura: CQRS, Repository Pattern
- ✅ API Platform: REST, operaciones personalizadas
- ✅ Producción: rendimiento, logging, despliegue
Cada pregunta merece una exploración más profunda con la documentación oficial de Symfony. Los reclutadores valoran a los candidatos que entienden las decisiones arquitectónicas del framework y saben justificar sus decisiones técnicas.
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Etiquetas
Compartir
Artículos relacionados

Doctrine ORM: Dominar las relaciones en Symfony
Guía completa de las relaciones Doctrine ORM en Symfony. OneToMany, ManyToMany, estrategias de carga y optimización del rendimiento con ejemplos prácticos.

Symfony 7: API Platform y Mejores Practicas
Guia completa de API Platform 4 con Symfony 7. Recursos, grupos de serializacion, state processors, filtros, seguridad y pruebas automatizadas para APIs REST profesionales.

Symfony Live Components y UX 3.0: Aplicaciones Reactivas Sin JavaScript en 2026
Descubre cómo Symfony Live Components revoluciona el desarrollo web con interactividad en tiempo real sin escribir JavaScript. Guía completa con ejemplos prácticos de UX 3.0.