Cómo construí este portfolio
PHP, DDD y Claude Code en la práctica
La pregunta inevitable: ¿PHP en 2026?
Antes de contar el proceso, respondo la pregunta que me hacen siempre: ¿por qué PHP en 2026?
No es nostalgia. Es una decisión deliberada.
PHP 8.1+ con Symfony 6.4 es lo que uso en producción en El Confidencial. Construir el portfolio en el mismo stack tiene una ventaja concreta: lo que ves aquí es exactamente como escribo código en producción real, no un escaparate con tecnología de moda para impresionar en Twitter.
Si alguien revisa este código y no le gusta PHP, lo entiendo. Pero si revisa el código, verá Arquitectura Hexagonal, DDD, TDD estricto y PHPStan level 9. Eso es lo que quiero demostrar, y PHP con Symfony me permite demostrarlo con un stack que conozco en profundidad.
El código fuente completo está en el repositorio público →
Paso 1: Diseño del dominio con Claude Desktop
Antes de abrir el IDE, usé Claude Desktop para pensar el dominio. Conversaciones largas sobre qué debería ser un Value Object en este contexto, dónde trazar los límites del bounded context, qué interfaces necesita el dominio para ser completamente independiente de Symfony.
El modelo me ayudó a formalizar lo que tenía en la cabeza en una estructura DDD coherente antes de escribir una sola línea de código. Ese proceso de "hablar sobre arquitectura" antes de implementar definió la estructura que tienes delante.
src/
├── Domain/
│ ├── Portfolio/
│ │ ├── Portfolio.php ← Entidad raíz
│ │ ├── PersonalInfo.php ← Value Object inmutable
│ │ ├── ContactInfo.php ← Value Object inmutable
│ │ ├── Skill.php ← Value Object
│ │ └── PortfolioRepositoryInterface.php ← Puerto
│ └── Article/
│ ├── Article.php ← Entidad
│ ├── Tag.php ← Value Object
│ └── ArticleRepositoryInterface.php ← Puerto
│
├── Infrastructure/
│ ├── Persistence/Json/
│ │ ├── JsonPortfolioRepository.php ← Adaptador
│ │ └── JsonArticleRepository.php
│ └── Twig/
│ ├── MarkdownExtension.php
│ └── VersionExtension.php
│
└── Controllers/
├── PortfolioController.php
└── ArticleController.php
El dominio no sabe nada de Symfony, JSON ni HTTP. Si mañana quisiera cambiar de JSON a MySQL, solo cambio el adaptador de infraestructura. El dominio no toca.
Paso 2: Implementación con Claude Code
Tras el diseño en Claude Desktop, la implementación usó Claude Code (CLI integrada en terminal) para asegurar que las buenas prácticas se aplicaban desde el primer commit: PHPStan level 9, Value Objects inmutables con readonly, y TDD estricto.
El ciclo TDD en la práctica
RED — el test que falla primero:
// tests/Unit/Domain/Portfolio/PersonalInfoTest.php
public function test_it_rejects_empty_name(): void
{
$this->expectException(\InvalidArgumentException::class);
new PersonalInfo(name: '', title: 'Dev', tagline: '...', bio: '...', location: '...');
}
GREEN — implementación mínima para que pase:
final readonly class PersonalInfo
{
public function __construct(
public readonly string $name,
public readonly string $title,
// ...
) {
if ('' === trim($this->name)) {
throw new \InvalidArgumentException('Name cannot be empty');
}
}
}
REFACTOR — limpieza sin romper tests: Readonly properties, validación completa en constructor, tipos explícitos en todo. PHPStan level 9 sin errores.
Este ciclo se repitió para cada Value Object, adaptador, extensión Twig y controlador del proyecto.
Los números reales
PHPUnit: 148 tests, 359 assertions. Corren en ~0.3 segundos.
Dominio: Unit tests de Value Objects, entidades
Infraestructura: Integration tests de repositorios JSON
Calidad: Test de cumplimiento de CS Fixer
Playwright E2E: más de 130 tests con POM estricto.
playwright/
├── components/ ← POM por página/componente
│ ├── header/ ← selectors.ts + index.ts
│ ├── footer/
│ ├── portfolio/ ← el de la página de keywords
│ └── ...
└── tests/
├── header/ ← funcionales + visual regression
├── portfolio/ ← smoke + modal interaction + visual
├── cv/
└── ...
Los selectores CSS solo existen en selectors.ts. Los tests solo usan métodos del POM. Si un selector cambia, cambias un archivo y todos los tests que lo usan siguen pasando.
Visual regression: cada componente crítico tiene snapshot baseline. Si un deploy cambia algo visible en el header, footer o cualquier página, el test detecta la diferencia antes de que llegue a producción.
El CI/CD: de commit a producción en 7 minutos
Cada PR tiene QA obligatorio (requerido para mergear):
PHPUnit → PHPStan level 9 → CS Fixer dry-run → ESLint
Cada merge a master dispara el pipeline completo:
1. Bump versión semántica automático
feat: → MINOR | fix:/chore: → PATCH
2. Deploy SSH al VPS de producción (OVH)
git pull en /var/www/portfolio
3. composer install --no-dev + optimize-autoloader
4. cache:clear + cache:warmup + reload php-fpm
5. E2E Playwright contra https://josemoreupeso.es
6. Back-merge automático master → develop [skip ci]
7. Sync al repositorio público
Sin intervención manual. Sin SSH de emergencia. Sin miedo a los viernes.
El viaje de v1.0.0 a v18.0.0
Más de 150 commits, 22+ issues cerradas en GitHub, cada versión es un deploy a producción. El proyecto comenzó con la arquitectura hexagonal vacía y fue creciendo feature a feature:
| Hito | Qué se añadió |
|---|---|
| v1–v5 | Dominio, adaptadores JSON, primeros 50 tests PHPUnit |
| v6–v10 | CI/CD completo, Tests E2E Playwright, visual regression |
| v11–v15 | Sección Code & AI, artículos en Markdown, dark mode |
| v16–v17 | Página /proyectos, proyecto TLOTP como primero |
| v18 | Página /portfolio con 17 keywords interactivos y modales |
Cada feature tiene su rama feature/t{num}_{desc}, su PR con QA automático, y su deploy automático al mergear. La historia de git es la historia del proyecto.
Arquitectura hexagonal: la prueba real
La Arquitectura Hexagonal en este portfolio no es teórica. Puedes verificarla:
- El
PortfolioControllerdepende dePortfolioRepositoryInterface(dominio). Nunca deJsonPortfolioRepository(infraestructura). - El dominio (
src/Domain/) no tiene ningúnuse Symfony\niuse Doctrine\. - Si cambias el adaptador JSON por uno de base de datos, el controlador no cambia.
Eso es Arquitectura Hexagonal: las dependencias apuntan siempre hacia el interior.
Si quieres explorar el código: repositorio público en GitHub →