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 publishedAt y scheduledAt son null; programado si solo tiene scheduledAt; publicado si publishedAt es una fecha pasada. Añade métodos isPublished() e isScheduled() 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 --webapp incluye un compose.yaml con PostgreSQL y Mailpit. Con docker compose up -d y symfony serve, Symfony CLI detecta los contenedores y define DATABASE_URL y MAILER_DSN automá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.truncate es 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 campo content, entre 3 y 1000 caracteres) e intégralo en BlogController::show(): si el usuario está autenticado y el formulario es válido, guarda el comentario con estado Pending, 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 comentarios Pending y dos botones por comentario (formularios POST con token CSRF) que cambien su estado a Approved o Spam.


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:list y article:read en las entidades.
  • DTOs ArticleInput, ArticlePatchInput y ArticleListQuery.
  • ArticleApiController con #[Route('/api/articles', format: 'json')].
  • Entidad ApiToken, ApiTokenHandler y firewall api stateless.
  • ApiExceptionListener para los errores en formato Problem Details.
  • CORS con NelmioCorsBundle para el dominio del frontend (si lo hay).
  • Un fichero requests/api.http con 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
Email 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:

  1. APP_ENV=prod, APP_SECRET y secretos (DATABASE_URL, MAILER_DSN) fuera de Git.
  2. composer install --no-dev --optimize-autoloader y composer dump-env prod.
  3. doctrine:migrations:migrate, asset-map:compile y cache:clear.
  4. php bin/console app:user:create-admin (solo la primera vez).
  5. Servidor web (Nginx + PHP-FPM o FrankenPHP) con document root en public/.
  6. Supervisor con workers para async y para scheduler_default.
  7. messenger:stop-workers y recarga de PHP en cada despliegue.
  8. Health check en /health y 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 qa pasa 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.md del 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.