Módulo 16: Proyecto final
Objetivos del módulo
- Construir de principio a fin el blog que ha servido de ejemplo durante todo el curso
- Partir de unos requisitos funcionales y traducirlos a un modelo de datos y un plan de trabajo
- Integrar todo lo aprendido: Doctrine, Twig, AssetMapper, formularios, validación, seguridad con voters, Messenger, Mailer, API REST, consola, tests y despliegue
- Organizar el código de un proyecto real y aplicar un checklist de calidad profesional (PHPStan, PHP-CS-Fixer, tests)
- Conocer las vías para seguir ampliando la aplicación
1. El proyecto: “Blog” con Symfony 8
📘 Concepto: Este módulo es una guía de proyecto, no una lección nueva. Cada fase indica qué construir, muestra el código de las piezas clave y remite al módulo donde se explicó en detalle. El objetivo es que tú escribas la aplicación completa, consultando los módulos anteriores cuando lo necesites.
Requisitos funcionales
Zona pública (cualquier visitante):
- Ver el listado paginado de artículos publicados (10 por página), del más reciente al más antiguo.
- Ver el detalle de un artículo por su slug, con su autor, categoría, etiquetas y comentarios aprobados.
- Navegar por categorías (
/categoria/{slug}). - Registrarse e iniciar sesión.
- Los usuarios registrados pueden comentar; los comentarios quedan pendientes de moderación.
Zona de autores (ROLE_AUTHOR):
- CRUD de sus propios artículos: crear, editar, borrar y listar (incluidos borradores).
- Publicar inmediatamente o programar la publicación para una fecha futura.
- No pueden editar ni borrar artículos de otros autores.
- Recibir un email cuando su artículo se publica.
Zona de administración (ROLE_ADMIN):
- Gestionar categorías (CRUD).
- Moderar comentarios: aprobar o marcar como spam.
- Puede editar o borrar cualquier artículo.
API y procesos:
- API REST de artículos (lectura pública, escritura con token Bearer).
- Comando que publica los artículos programados, ejecutado periódicamente por el Scheduler.
Requisitos no funcionales
- Symfony 8.1, PHP 8.4+ (recomendado 8.5), Doctrine ORM 3, PostgreSQL (o MySQL/MariaDB).
- Interfaz con Twig + Bootstrap 5.3 servido con AssetMapper (sin Node.js).
- Envío de emails asíncrono con Messenger.
- Tests funcionales de los flujos principales y CI en GitHub Actions.
- Código analizado con PHPStan y formateado con PHP-CS-Fixer.
2. Modelo de datos
erDiagram
USER ||--o{ ARTICLE : escribe
USER ||--o{ COMMENT : escribe
USER ||--o{ API_TOKEN : posee
CATEGORY ||--o{ ARTICLE : agrupa
ARTICLE ||--o{ COMMENT : recibe
ARTICLE }o--o{ TAG : etiquetado
USER {
int id PK
string email UK
string name
json roles
string password
}
ARTICLE {
int id PK
string title
string slug UK
text content
datetime createdAt
datetime publishedAt "null = borrador"
datetime scheduledAt "null = no programado"
int author_id FK
int category_id FK
}
CATEGORY {
int id PK
string name
string slug UK
}
TAG {
int id PK
string name UK
}
COMMENT {
int id PK
text content
datetime createdAt
string status "pending/approved/spam"
int article_id FK
int author_id FK
}
API_TOKEN {
int id PK
string tokenHash UK
datetime expiresAt
int owner_id FK
}
| Entidad | Relaciones | Notas |
|---|---|---|
User | 1-N Article, 1-N Comment, 1-N ApiToken | Implementa UserInterface y PasswordAuthenticatedUserInterface |
Article | N-1 User, N-1 Category, 1-N Comment, N-M Tag | Estado derivado de publishedAt y scheduledAt |
Category | 1-N Article | Slug único para la URL |
Tag | N-M Article | Nombre único |
Comment | N-1 Article, N-1 User | status con el enum CommentStatus |
ApiToken | N-1 User | Solo se guarda el hash del token (módulo 12) |
💡 Consejo: El “estado” de un artículo no necesita una columna propia: borrador si
publishedAtyscheduledAtsonnull; programado si solo tienescheduledAt; publicado sipublishedAtes una fecha pasada. Añade métodosisPublished()eisScheduled()en la entidad para no repetir esa lógica.
3. Plan por fases
| Fase | Objetivo | Módulos de referencia |
|---|---|---|
| 1 | Setup del proyecto y base de datos | 01, 05 |
| 2 | Entidades, migraciones y fixtures | 05, 06 |
| 3 | Frontend: Twig + Bootstrap con AssetMapper, listado y detalle | 02, 03, 04 |
| 4 | Formularios y validación (artículos y comentarios) | 07, 08 |
| 5 | Seguridad: registro, login, roles y voters | 10 |
| 6 | Email asíncrono al publicar | 11 |
| 7 | API REST de artículos con token | 12 |
| 8 | Comando de publicación programada + Scheduler | 14 |
| 9 | Tests funcionales y CI | 13 |
| 10 | Despliegue | 15 |
💡 Consejo: Haz un commit (o una pull request) por fase y no pases a la siguiente sin que funcione la anterior. Desde la fase 3, ve escribiendo los tests de cada funcionalidad en cuanto la terminas, no todos al final.
4. Fase 1: setup
symfony new blog --webapp
cd blog
# dependencias que usaremos a lo largo del proyecto
composer require symfony/scheduler dragonmantank/cron-expression nelmio/cors-bundle
composer require --dev zenstruck/foundry dama/doctrine-test-bundle \
phpstan/phpstan phpstan/phpstan-symfony phpstan/phpstan-doctrine phpstan/extension-installer \
friendsofphp/php-cs-fixer
symfony check:requirements
Configura la base de datos en .env.local (no se sube a Git):
DATABASE_URL="postgresql://app:app@127.0.0.1:5432/blog?serverVersion=17&charset=utf8"
MAILER_DSN=smtp://localhost:1025
php bin/console doctrine:database:create
symfony serve -d
💡 Consejo: El esqueleto
--webappincluye uncompose.yamlcon PostgreSQL y Mailpit. Condocker compose up -dysymfony serve, Symfony CLI detecta los contenedores y defineDATABASE_URLyMAILER_DSNautomáticamente.
5. Fase 2: entidades y fixtures
Crea las entidades con make:entity (y make:user para User), siguiendo el modelo de la sección 2:
php bin/console make:user User # email como identificador, contraseña con hash
php bin/console make:entity Category
php bin/console make:entity Tag
php bin/console make:entity Article # relaciones ManyToOne author/category, ManyToMany tags
php bin/console make:entity Comment
php bin/console make:migration
php bin/console doctrine:migrations:migrate
El enum de estado de los comentarios
<?php
// src/Enum/CommentStatus.php
declare(strict_types=1);
namespace App\Enum;
enum CommentStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Spam = 'spam';
public function label(): string
{
return match ($this) {
self::Pending => 'Pendiente',
self::Approved => 'Aprobado',
self::Spam => 'Spam',
};
}
}
// src/Entity/Comment.php (fragmento)
#[ORM\Column(length: 20, enumType: CommentStatus::class)]
private CommentStatus $status = CommentStatus::Pending;
Métodos de dominio en Article
// src/Entity/Article.php (fragmento)
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $scheduledAt = null;
public function isPublished(?\DateTimeImmutable $now = null): bool
{
return null !== $this->publishedAt && $this->publishedAt <= ($now ?? new \DateTimeImmutable());
}
public function isScheduled(): bool
{
return null === $this->publishedAt && null !== $this->scheduledAt;
}
/** @return Collection<int, Comment> */
public function getApprovedComments(): Collection
{
return $this->comments->filter(fn (Comment $c): bool => CommentStatus::Approved === $c->getStatus());
}
Fixtures con Foundry
Genera las factories (php bin/console make:factory) y combínalas en AppFixtures:
<?php
// src/DataFixtures/AppFixtures.php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Enum\CommentStatus;
use App\Factory\ArticleFactory;
use App\Factory\CategoryFactory;
use App\Factory\CommentFactory;
use App\Factory\TagFactory;
use App\Factory\UserFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
UserFactory::createOne([
'email' => 'admin@blog.test', 'name' => 'Admin', 'password' => 'admin1234567',
'roles' => ['ROLE_ADMIN'],
]);
$authors = UserFactory::createMany(3, ['roles' => ['ROLE_AUTHOR'], 'password' => 'autor1234567']);
$readers = UserFactory::createMany(10);
CategoryFactory::createSequence([
['name' => 'Symfony', 'slug' => 'symfony'],
['name' => 'Doctrine', 'slug' => 'doctrine'],
['name' => 'Frontend', 'slug' => 'frontend'],
['name' => 'Despliegue', 'slug' => 'despliegue'],
]);
TagFactory::createMany(12);
// 30 publicados, 5 borradores y 3 programados
ArticleFactory::createMany(30, fn () => [
'author' => $authors[array_rand($authors)],
'category' => CategoryFactory::random(),
'tags' => TagFactory::randomRange(0, 3),
]);
ArticleFactory::new()->draft()->many(5)->create(fn () => ['author' => $authors[array_rand($authors)]]);
ArticleFactory::new()->scheduledFor(new \DateTimeImmutable('+2 days'))->many(3)->create();
CommentFactory::createMany(120, fn () => [
'article' => ArticleFactory::random(),
'author' => $readers[array_rand($readers)],
'status' => CommentStatus::Approved,
]);
CommentFactory::createMany(10, fn () => ['article' => ArticleFactory::random(), 'status' => CommentStatus::Pending]);
CommentFactory::createMany(5, fn () => ['article' => ArticleFactory::random(), 'status' => CommentStatus::Spam]);
}
}
php bin/console doctrine:fixtures:load --no-interaction
6. Fase 3: frontend con Twig + Bootstrap (AssetMapper)
php bin/console importmap:require bootstrap
// assets/app.js
import './bootstrap.js'; // Stimulus
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap';
import './styles/app.css';
El controlador público
<?php
// src/Controller/BlogController.php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\ArticleListQuery;
use App\Entity\Article;
use App\Entity\Category;
use App\Repository\ArticleRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final class BlogController extends AbstractController
{
#[Route('/', name: 'app_home', methods: ['GET'])]
#[Route('/categoria/{slug}', name: 'app_category', methods: ['GET'])]
public function index(
ArticleRepository $articles,
#[MapQueryParameter] int $page = 1,
?string $slug = null,
): Response {
$query = new ArticleListQuery(page: max(1, $page), limit: 10, category: $slug);
$paginator = $articles->paginatePublished($query);
return $this->render('blog/index.html.twig', [
'articles' => $paginator,
'page' => $query->page,
'pages' => (int) ceil(count($paginator) / $query->limit),
'category' => $slug,
]);
}
#[Route('/articulos/{slug}', name: 'app_article_show', methods: ['GET', 'POST'])]
public function show(#[MapEntity(mapping: ['slug' => 'slug'])] Article $article): Response
{
if (!$article->isPublished() && !$this->isGranted('ARTICLE_EDIT', $article)) {
throw new NotFoundHttpException(); // los borradores solo los ve su autor
}
// el formulario de comentarios se añade en la fase 4
return $this->render('blog/show.html.twig', ['article' => $article]);
}
}
La plantilla del listado
{# templates/blog/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Blog{% if category %} · {{ category|capitalize }}{% endif %}{% endblock %}
{% block body %}
<h1 class="mb-4">Últimos artículos</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
{% for article in articles %}
<div class="col">
<article class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">
<a href="{{ path('app_article_show', {slug: article.slug}) }}">{{ article.title }}</a>
</h2>
<p class="card-text text-body-secondary small">
{{ article.publishedAt|date('d/m/Y') }} · {{ article.author.name }}
{% if article.category %}
· <a href="{{ path('app_category', {slug: article.category.slug}) }}">{{ article.category.name }}</a>
{% endif %}
</p>
<p class="card-text">{{ article.content|striptags|u.truncate(160, '…', false) }}</p>
</div>
</article>
</div>
{% else %}
<p class="text-body-secondary">Todavía no hay artículos publicados.</p>
{% endfor %}
</div>
{% if pages > 1 %}
<nav class="mt-4" aria-label="Paginación">
<ul class="pagination justify-content-center">
{% for p in 1..pages %}
<li class="page-item {{ p == page ? 'active' }}">
<a class="page-link" href="{{ path(app.current_route, app.current_route_parameters|merge({page: p})) }}">{{ p }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% endblock %}
💡 Consejo: El filtro
u.truncatees del componente String (twig/string-extra, incluido en--webapp). Para muchas páginas, muestra solo un rango alrededor de la actual con una macro de paginación (módulo 03).
7. Fase 4: formularios y validación
Formulario de artículo
<?php
// src/Form/ArticleType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use App\Entity\Tag;
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\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', null, ['label' => 'Título'])
->add('content', TextareaType::class, ['label' => 'Contenido', 'attr' => ['rows' => 12]])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Sin categoría',
'required' => false,
'label' => 'Categoría',
])
->add('tags', EntityType::class, [
'class' => Tag::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'required' => false,
'label' => 'Etiquetas',
])
->add('scheduledAt', DateTimeType::class, [
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
'label' => 'Programar publicación (vacío = guardar como borrador)',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Article::class]);
}
}
Restricciones en la entidad:
// src/Entity/Article.php (fragmento)
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[UniqueEntity(fields: ['slug'], message: 'Ya existe un artículo con un título similar.')]
class Article
{
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 5, max: 255)]
private string $title = '';
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
#[Assert\Length(min: 50)]
private string $content = '';
#[ORM\Column(nullable: true)]
#[Assert\GreaterThan('now', message: 'La fecha programada debe estar en el futuro.')]
private ?\DateTimeImmutable $scheduledAt = null;
// ...
}
Controlador del autor
<?php
// src/Controller/Author/ArticleController.php
declare(strict_types=1);
namespace App\Controller\Author;
use App\Entity\Article;
use App\Entity\User;
use App\Form\ArticleType;
use App\Repository\ArticleRepository;
use App\Service\ArticlePublisher;
use App\Service\Slugger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/autor/articulos', name: 'author_article_')]
#[IsGranted('ROLE_AUTHOR')]
final class ArticleController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Slugger $slugger,
) {
}
#[Route('', name: 'index', methods: ['GET'])]
public function index(ArticleRepository $articles, #[CurrentUser] User $user): Response
{
return $this->render('author/article/index.html.twig', [
'articles' => $articles->findBy(['author' => $user], ['createdAt' => 'DESC']),
]);
}
#[Route('/nuevo', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request, #[CurrentUser] User $user): Response
{
$article = (new Article())->setAuthor($user);
return $this->handleForm($request, $article, 'Artículo creado.');
}
#[Route('/{id}/editar', name: 'edit', methods: ['GET', 'POST'])]
#[IsGranted('ARTICLE_EDIT', subject: 'article')]
public function edit(Request $request, Article $article): Response
{
return $this->handleForm($request, $article, 'Artículo actualizado.');
}
#[Route('/{id}/publicar', name: 'publish', methods: ['POST'])]
#[IsGranted('ARTICLE_EDIT', subject: 'article')]
public function publish(Request $request, Article $article, ArticlePublisher $publisher): Response
{
if ($this->isCsrfTokenValid('publish'.$article->getId(), $request->getPayload()->getString('_token'))) {
$publisher->publishNow($article); // publica, hace flush y envía el aviso
$this->addFlash('success', '¡Artículo publicado!');
}
return $this->redirectToRoute('author_article_index');
}
#[Route('/{id}', name: 'delete', methods: ['POST'])]
#[IsGranted('ARTICLE_DELETE', subject: 'article')]
public function delete(Request $request, Article $article): Response
{
if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
$this->em->remove($article);
$this->em->flush();
$this->addFlash('success', 'Artículo eliminado.');
}
return $this->redirectToRoute('author_article_index');
}
private function handleForm(Request $request, Article $article, string $message): Response
{
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$article->setSlug($this->slugger->slugify($article->getTitle()));
$this->em->persist($article);
$this->em->flush();
$this->addFlash('success', $message);
return $this->redirectToRoute('author_article_index');
}
// con un formulario inválido, render() responde 422 automáticamente
return $this->render('author/article/form.html.twig', ['form' => $form, 'article' => $article]);
}
}
✏️ Ejercicio: Crea
CommentType(solo el campocontent, entre 3 y 1000 caracteres) e intégralo enBlogController::show(): si el usuario está autenticado y el formulario es válido, guarda el comentario con estadoPending, añade un flash “Tu comentario está pendiente de moderación” y redirige al mismo artículo.
8. Fase 5: seguridad con voters
# config/packages/security.yaml (fragmento)
security:
role_hierarchy:
ROLE_AUTHOR: ROLE_USER
ROLE_ADMIN: ROLE_AUTHOR
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/autor, roles: ROLE_AUTHOR }
- { path: ^/api/, roles: PUBLIC_ACCESS, methods: [GET] }
- { path: ^/api/, roles: ROLE_AUTHOR }
El voter concentra la regla “solo el autor o un admin”:
<?php
// src/Security/Voter/ArticleVoter.php
declare(strict_types=1);
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\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** @extends Voter<string, Article> */
final class ArticleVoter extends Voter
{
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
public function __construct(private readonly AccessDecisionManagerInterface $decisionManager)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($this->decisionManager->decide($token, ['ROLE_ADMIN'])) {
return true; // el admin puede con todo
}
if ($subject->getAuthor() === $user) {
return true;
}
$vote?->addReason('Solo el autor del artículo puede modificarlo.');
return false;
}
}
En Twig, muestra los botones solo a quien puede usarlos:
{% if is_granted('ARTICLE_EDIT', article) %}
<a class="btn btn-outline-primary btn-sm" href="{{ path('author_article_edit', {id: article.id}) }}">Editar</a>
{% endif %}
✏️ Ejercicio: Implementa la moderación en
src/Controller/Admin/CommentController.php: una página con los comentariosPendingy dos botones por comentario (formulariosPOSTcon token CSRF) que cambien su estado aApprovedoSpam.
9. Fase 6: email asíncrono al publicar
Toda publicación (manual o programada) pasa por ArticlePublisher (módulo 13), que despacha un mensaje tras el flush(). Añádele un método publishNow(Article $article) que llame a publish(), haga flush() y despache el mensaje, para usarlo desde el controlador del autor.
<?php
// src/Message/SendNewArticleNotification.php
declare(strict_types=1);
namespace App\Message;
final readonly class SendNewArticleNotification
{
public function __construct(public int $articleId)
{
}
}
<?php
// src/MessageHandler/SendNewArticleNotificationHandler.php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\SendNewArticleNotification;
use App\Repository\ArticleRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
#[AsMessageHandler]
final readonly class SendNewArticleNotificationHandler
{
public function __construct(
private ArticleRepository $articles,
private MailerInterface $mailer,
) {
}
public function __invoke(SendNewArticleNotification $message): void
{
$article = $this->articles->find($message->articleId);
if (null === $article) {
return; // el artículo se borró antes de procesar el mensaje
}
$email = (new TemplatedEmail())
->from(new Address('no-reply@blog.example.com', 'Blog'))
->to(new Address($article->getAuthor()->getEmail(), $article->getAuthor()->getName()))
->subject(sprintf('Tu artículo "%s" ya está publicado', $article->getTitle()))
->htmlTemplate('emails/article_published.html.twig')
->context(['article' => $article]);
$this->mailer->send($email);
}
}
# config/packages/messenger.yaml (fragmento)
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Message\SendNewArticleNotification: async
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
⚠️ Importante: El mensaje lleva el id, no la entidad. El worker se ejecuta en otro proceso (y quizá minutos después): debe cargar el artículo fresco desde la base de datos.
10. Fase 7: API REST de artículos
Implementa la API tal como la construiste en el módulo 12. Resumen de los endpoints:
| Método | Ruta | Acceso | Respuesta |
|---|---|---|---|
GET | /api/articles?page=&limit=&category=&q= | Público | 200 + items y meta |
GET | /api/articles/{id} | Público | 200 / 404 |
POST | /api/articles | Token + ROLE_AUTHOR | 201 + Location / 422 |
PUT | /api/articles/{id} | Token + voter | 200 / 403 / 422 |
PATCH | /api/articles/{id} | Token + voter | 200 / 403 / 422 |
DELETE | /api/articles/{id} | Token + voter | 204 / 403 |
Piezas necesarias:
- Grupos
article:listyarticle:readen las entidades. - DTOs
ArticleInput,ArticlePatchInputyArticleListQuery. ArticleApiControllercon#[Route('/api/articles', format: 'json')].- Entidad
ApiToken,ApiTokenHandlery firewallapistateless. ApiExceptionListenerpara los errores en formato Problem Details.- CORS con NelmioCorsBundle para el dominio del frontend (si lo hay).
- Un fichero
requests/api.httpcon ejemplos de todas las operaciones.
11. Fase 8: publicación programada
El comando app:articles:publish-scheduled del módulo 14 llama a ArticlePublisher::publishDue(). Prográmalo con el Scheduler:
<?php
// src/Scheduler/BlogSchedule.php
declare(strict_types=1);
namespace App\Scheduler;
use Symfony\Component\Console\Messenger\RunCommandMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule('default')]
final class BlogSchedule implements ScheduleProviderInterface
{
public function __construct(private readonly CacheInterface $cache)
{
}
public function getSchedule(): Schedule
{
return (new Schedule())
->with(
RecurringMessage::every('5 minutes', new RunCommandMessage('app:articles:publish-scheduled')),
RecurringMessage::cron('#midnight', new RunCommandMessage('app:comments:purge-spam --days=30 --force')),
)
->stateful($this->cache);
}
}
php bin/console debug:scheduler
php bin/console messenger:consume async scheduler_default -vv
12. Fase 9: tests funcionales
Configura PHPUnit con DAMADoctrineTestBundle y Foundry (módulo 13) y cubre, como mínimo, estos casos:
| Área | Test |
|---|---|
| Público | La portada lista 10 artículos publicados y no muestra borradores ni programados |
| Público | La página 2 muestra el resto; una categoría filtra correctamente |
| Público | Un slug inexistente devuelve 404; un borrador ajeno, también |
| Comentarios | Un anónimo no ve el formulario; un usuario comenta y queda pendiente |
| Autores | Crear artículo (redirección + existe en BD); formulario inválido → 422 |
| Voter | Un autor no puede editar un artículo ajeno (403); un admin sí (200) |
| Admin | /admin/comentarios: anónimo → /login, autor → 403, admin → 200 |
| Publicar un artículo encola un mensaje / envía un email al autor | |
| API | Listado paginado, 401 sin token, 201 con token, 422 con datos inválidos |
| Consola | app:articles:publish-scheduled publica solo los vencidos |
Ejemplo de test de extremo a extremo del flujo de un autor:
<?php
// tests/Controller/Author/ArticleControllerTest.php
declare(strict_types=1);
namespace App\Tests\Controller\Author;
use App\Factory\ArticleFactory;
use App\Factory\UserFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;
#[ResetDatabase]
final class ArticleControllerTest extends WebTestCase
{
public function testAuthorWritesAndPublishesArticle(): void
{
$client = static::createClient();
$author = UserFactory::createOne(['roles' => ['ROLE_AUTHOR']]);
$client->loginUser($author);
$client->request('GET', '/autor/articulos/nuevo');
$client->submitForm('Guardar', [
'article[title]' => 'Mi proyecto final con Symfony',
'article[content]' => str_repeat('Contenido del artículo de prueba. ', 5),
]);
$this->assertResponseRedirects('/autor/articulos');
$client->followRedirect();
$this->assertSelectorTextContains('table', 'Mi proyecto final con Symfony');
$this->assertSelectorTextContains('table', 'Borrador');
$client->submitForm('Publicar');
$client->followRedirect();
$this->assertSelectorExists('.alert-success');
ArticleFactory::assert()->count(1, ['title' => 'Mi proyecto final con Symfony']);
}
}
Y la CI de GitHub Actions del módulo 13, ampliada con los pasos de calidad de la sección 15.
13. Fase 10: despliegue
Sigue el checklist del módulo 15. Para este proyecto, el despliegue debe incluir:
APP_ENV=prod,APP_SECRETy secretos (DATABASE_URL,MAILER_DSN) fuera de Git.composer install --no-dev --optimize-autoloaderycomposer dump-env prod.doctrine:migrations:migrate,asset-map:compileycache:clear.php bin/console app:user:create-admin(solo la primera vez).- Servidor web (Nginx + PHP-FPM o FrankenPHP) con document root en
public/. - Supervisor con workers para
asyncy parascheduler_default. messenger:stop-workersy recarga de PHP en cada despliegue.- Health check en
/healthy alertas de errores.
14. Estructura final del proyecto
blog/
├── .github/workflows/tests.yaml
├── assets/
│ ├── app.js
│ ├── bootstrap.js
│ ├── controllers/ # controladores Stimulus
│ └── styles/app.css
├── config/
│ ├── packages/ # security, messenger, doctrine, nelmio_cors...
│ ├── secrets/prod/ # vault (sin la clave privada en Git)
│ └── services.yaml
├── migrations/
├── public/index.php
├── requests/api.http
├── src/
│ ├── Command/ # PublishScheduledArticles, PurgeSpamComments, CreateAdmin
│ ├── Controller/
│ │ ├── Admin/ # CategoryController, CommentController
│ │ ├── Api/ # ArticleApiController
│ │ ├── Author/ # ArticleController
│ │ ├── BlogController.php
│ │ ├── RegistrationController.php
│ │ └── SecurityController.php
│ ├── DataFixtures/AppFixtures.php
│ ├── Dto/ # ArticleInput, ArticlePatchInput, ArticleListQuery
│ ├── Entity/ # Article, Category, Comment, Tag, User, ApiToken
│ ├── Enum/CommentStatus.php
│ ├── EventListener/ApiExceptionListener.php
│ ├── Factory/ # factories de Foundry
│ ├── Form/ # ArticleType, CommentType, CategoryType, RegistrationFormType
│ ├── Message/SendNewArticleNotification.php
│ ├── MessageHandler/SendNewArticleNotificationHandler.php
│ ├── Repository/
│ ├── Scheduler/BlogSchedule.php
│ ├── Security/ # ApiTokenHandler, Voter/ArticleVoter
│ └── Service/ # ArticlePublisher, Slugger, ReadingTimeCalculator
├── templates/
│ ├── admin/ author/ blog/ emails/ security/
│ ├── bundles/TwigBundle/Exception/ # páginas de error
│ └── base.html.twig
├── tests/
│ ├── Api/ Command/ Controller/ Repository/ Service/
│ └── bootstrap.php
├── .php-cs-fixer.dist.php
├── phpstan.dist.neon
├── phpunit.dist.xml
└── rector.php
15. Checklist de calidad
PHPStan: análisis estático
# phpstan.dist.neon
parameters:
level: 6
paths:
- src
- tests
Con phpstan/extension-installer, las extensiones de Symfony y Doctrine se activan solas: PHPStan entenderá $container->get(), los repositorios y los tipos de las entidades.
vendor/bin/phpstan analyse
💡 Consejo: Empieza en un nivel bajo (5 o 6) con cero errores y súbelo poco a poco. Si heredas un proyecto con muchos errores, genera una baseline (
--generate-baseline) y exige que no aparezcan errores nuevos.
PHP-CS-Fixer: estilo de código
<?php
// .php-cs-fixer.dist.php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude(['var', 'vendor', 'public']);
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'declare_strict_types' => true,
])
->setRiskyAllowed(true)
->setFinder($finder);
vendor/bin/php-cs-fixer fix --dry-run --diff # qué cambiaría
vendor/bin/php-cs-fixer fix # lo aplica
Un único comando de calidad
{
"scripts": {
"qa": [
"php-cs-fixer fix --dry-run --diff",
"phpstan analyse --no-progress",
"@php bin/console lint:twig templates",
"@php bin/console lint:yaml config",
"@php bin/console lint:container",
"@php bin/console doctrine:schema:validate --skip-sync",
"@php bin/phpunit"
]
}
}
composer qa
Checklist antes de dar el proyecto por terminado
- Todos los requisitos funcionales de la sección 1 implementados.
composer qapasa sin errores en local y en la CI.- Ninguna deprecation en el profiler ni en los tests.
- Los borradores y artículos programados nunca aparecen en la zona pública ni en la API.
- Ninguna acción que modifica datos responde a
GET; todos los formularios llevan CSRF. - La API devuelve los códigos correctos (201, 204, 401, 403, 404, 422) y errores en JSON.
- Los emails se envían por Messenger y el worker está documentado en el
README.md. - El
README.mddel proyecto explica cómo instalarlo, cargar las fixtures y ejecutar los tests.
16. Ejercicios
Has terminado la versión base. Estas ampliaciones son proyectos en sí mismos; elige las que más te interesen.
Ejercicio 1: Backoffice con EasyAdmin
Instala easycorp/easyadmin-bundle, genera un dashboard con php bin/console make:admin:dashboard y CRUDs con make:admin:crud para Category, Tag y Comment (con filtros por estado y acciones personalizadas “Aprobar” y “Marcar como spam”). Protege el dashboard con ROLE_ADMIN y compara el código con tus controladores de administración hechos a mano.
Ejercicio 2: Búsqueda
Añade una caja de búsqueda en la cabecera que busque en el título y el contenido de los artículos publicados (/buscar?q=). Empieza con LIKE en el repositorio; después investiga la búsqueda de texto completo de PostgreSQL o un motor externo como Meilisearch, y mide la diferencia con 10 000 artículos generados con Foundry.
Ejercicio 3: Internacionalización (i18n)
Traduce la interfaz al inglés: prefija las rutas con {_locale} (es, en), extrae los textos con php bin/console translation:extract --force es y en, usa el filtro trans en las plantillas y añade un selector de idioma que conserve la página actual.
Ejercicio 4: Live Components
Instala symfony/ux-live-component y convierte la búsqueda del ejercicio 2 en un componente con #[AsLiveComponent] y una propiedad #[LiveProp(writable: true)] public string $query = '', de modo que los resultados se actualicen mientras el usuario escribe, sin escribir JavaScript.
Ejercicio 5: API Platform
En una rama aparte, expón Article, Category y Comment con #[ApiResource]: lectura pública solo de lo publicado (con un state provider o una extensión de Doctrine), escritura con security: "is_granted('ARTICLE_EDIT', object)" reutilizando tu voter, filtros con QueryParameter y paginación de 10 elementos. Compara Swagger UI en /api con la documentación de NelmioApiDocBundle.
Ejercicio 6: Suscriptores y boletín semanal
Crea una entidad Subscriber (email confirmado mediante un enlace firmado), un formulario de suscripción en el pie de página y una tarea del Scheduler que, cada lunes, envíe por Messenger un boletín con los artículos de la semana. Cubre el flujo con tests.
Ejercicio 7: Despliegue real
Despliega el blog en un VPS con Deployer o en Upsun, con HTTPS, workers bajo Supervisor y health check. Documenta en el README.md del proyecto cada paso y el tiempo que tarda un despliegue completo.
Resumen
| Fase | Piezas clave |
|---|---|
| Setup | symfony new blog --webapp, .env.local, compose.yaml |
| Datos | make:user, make:entity, migraciones, enum CommentStatus, Foundry + AppFixtures |
| Frontend | AssetMapper + importmap:require bootstrap, BlogController, paginación en Twig |
| Formularios | ArticleType, CommentType, restricciones, UniqueEntity, 422 automático |
| Seguridad | role_hierarchy, access_control, ArticleVoter, is_granted() en Twig |
| Email asíncrono | ArticlePublisher → SendNewArticleNotification → handler con TemplatedEmail |
| API | DTOs + #[MapRequestPayload], grupos, token Bearer, Problem Details |
| Programación | Comando app:articles:publish-scheduled + BlogSchedule (Scheduler) |
| Tests | WebTestCase, Foundry, DAMA, loginUser(), jsonRequest(), CI |
| Calidad | PHPStan, PHP-CS-Fixer, lint:*, composer qa |
| Despliegue | Checklist de producción, workers, messenger:stop-workers |
| Ampliaciones | EasyAdmin, búsqueda, i18n, Live Components, API Platform |
¡Enhorabuena! Si has llegado hasta aquí y tu blog funciona, ya tienes una aplicación Symfony completa y profesional en tu portafolio.