Lección 6 de 8. Hasta aquí todo cabía en un archivo. Los proyectos reales tienen muchas clases y usan bibliotecas de otros, y eso exige orden: espacios de nombres, autocarga y Composer. Esta lección los monta en un proyecto de verdad y añade las dos herramientas que separan el código aficionado del profesional: las pruebas con PHPUnit y el análisis con PHPStan. Y una costumbre de seguridad: vigilar tus dependencias.
Espacios de nombres
En un proyecto real conviven tus clases y las de decenas de bibliotecas, y es fácil que dos se llamen igual: Cliente, Config, Response… Un espacio de nombres (namespace) es como el apellido de una clase: App\Carrito y Tienda\Carrito son dos clases distintas.
<?php
declare(strict_types=1);
namespace App\Facturacion;
use DateTimeImmutable;
use InvalidArgumentException as ArgumentoInvalido;
final class Factura
{
public function __construct(public readonly DateTimeImmutable $fecha) {}
}
$f = new Factura(new DateTimeImmutable('2026-10-27'));
echo get_class($f), "\n";
echo Factura::class, "\n";
echo $f->fecha->format('d/m/Y'), "\n";
echo ArgumentoInvalido::class, "\n";salidaApp\Facturacion\Factura App\Facturacion\Factura 27/10/2026 InvalidArgumentException
namespace va al principio del archivo, después de declare. use importa una clase de otro espacio de nombres para escribirla por su nombre corto (y con as, darle otro); las clases de PHP, como DateTimeImmutable, viven en el espacio global. Factura::class devuelve el nombre completo como texto, muy útil para no escribirlo a mano.
Composer: dependencias y autocarga
Composer es el gestor de dependencias de PHP: descarga las bibliotecas que tu proyecto necesita desde Packagist, con las versiones correctas, y genera un autoloader que carga cada clase cuando se usa por primera vez, sin require a mano. Se instala desde getcomposer.org:
composer --version
salidaComposer version 2.10.3 2026-08-27 13:34:23 PHP version 8.5.12 (/usr/local/bin/php)
Un proyecto se describe en composer.json. Este es el de nuestra tienda de ejemplo antes de añadir nada:
{
"name": "decodigo/tienda",
"description": "Ejemplo de la lección 6 del curso de PHP de decodigo.com",
"type": "project",
"require": {
"php": ">=8.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
}
}La parte clave es autoload: la regla PSR-4 dice que las clases del espacio de nombres App\ están en la carpeta src/, y las de App\Tests\, en tests/.
Añadir una dependencia
Nuestro carrito necesita un identificador único. En vez de inventarlo, usamos la biblioteca ramsey/uuid, una de las más usadas:
composer require ramsey/uuid
salida./composer.json has been updated Running composer update ramsey/uuid Loading composer repositories with package information Updating dependencies Lock file operations: 3 installs, 0 updates, 0 removals - Locking brick/math (1.0.0) - Locking ramsey/collection (2.1.1) - Locking ramsey/uuid (4.9.4) Writing lock file Installing dependencies from lock file (including require-dev) Package operations: 3 installs, 0 updates, 0 removals ... Generating autoload files 1 package you are using is looking for funding. Use the `composer fund` command to find out more! No security vulnerability advisories found. Using version ^4.9 for ramsey/uuid
Composer resolvió qué versiones encajan (ramsey/uuid necesita otras dos bibliotecas), las descargó en la carpeta vendor/, apuntó la dependencia en composer.json con ^4.9 (cualquier 4.x a partir de la 4.9, sin pasar a la 5) y escribió las versiones exactas en composer.lock. Fíjate en la última comprobación: «No security vulnerability advisories found». Volveremos a ella.
Con eso, nuestras clases ya pueden usar Ramsey\Uuid\Uuid. Esta es src/Carrito.php:
<?php
declare(strict_types=1);
namespace App;
use Ramsey\Uuid\Uuid;
final class Carrito
{
public readonly string $id;
/** @var list<Linea> */
private array $lineas = [];
public function __construct()
{
$this->id = Uuid::uuid7()->toString();
}
public function agregar(string $producto, int $precioCentimos, int $cantidad = 1): void
{
$this->lineas[] = new Linea($producto, $precioCentimos, $cantidad);
}
public function total(): int
{
return array_sum(array_map(fn(Linea $l): int => $l->subtotal(), $this->lineas));
}
public function totalConDescuento(int $porcentaje): int
{
if ($porcentaje < 0 || $porcentaje > 100) {
throw new \InvalidArgumentException('El descuento va de 0 a 100');
}
return intdiv($this->total() * (100 - $porcentaje), 100);
}
}El comentario /** @var list<Linea> */ no lo usa PHP, pero sí las herramientas de análisis: dice que el array solo contiene objetos Linea. Para usar todo esto basta con cargar el autoloader de Composer una vez, al principio del programa. Este es bin/demo.php:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Carrito;
$carrito = new Carrito();
$carrito->agregar('Café de Veracruz', 1250, 2);
$carrito->agregar('Taza', 800);
echo "Carrito {$carrito->id}\n";
echo 'Total: ', number_format($carrito->total() / 100, 2), "\n";
echo 'Con un 10 % de descuento: ', number_format($carrito->totalConDescuento(10) / 100, 2), "\n";php bin/demo.php
salidaCarrito 01a17ff6-e317-73c9-a408-58909a75eb6c Total: 33.00 Con un 10 % de descuento: 29.70
Ningún require de Carrito.php ni de Linea.php: el autoloader los encontró por su nombre. El identificador es un UUID versión 7, que empieza por la fecha y la hora, así que los carritos se ordenan solos por fecha de creación.
| Orden | Qué hace | Cuándo |
|---|---|---|
composer require x/y | añade una dependencia y la instala | al empezar a usar una biblioteca |
composer require --dev x/y | dependencia solo para desarrollar | pruebas, análisis, depuración |
composer install | instala las versiones exactas de composer.lock | al clonar el proyecto o desplegarlo |
composer update | busca versiones nuevas dentro de lo permitido | a propósito, revisando qué cambia |
composer audit | busca fallos de seguridad publicados | a menudo; mejor, automático |
composer outdated | lista lo que tiene versiones más nuevas | para planificar actualizaciones |
composer.json y composer.lock, sí: el segundo garantiza que todo el equipo y el servidor usen exactamente las mismas versiones. La carpeta vendor/, no: se regenera con composer install. Y en producción, composer install --no-dev, para no instalar las herramientas de desarrollo.Pruebas con PHPUnit
Una prueba automática es código que comprueba que otro código hace lo que debe. PHPUnit es el estándar en PHP. Se instala como dependencia de desarrollo, junto con PHPStan, que veremos después:
composer require --dev phpunit/phpunit phpstan/phpstan
Las pruebas van en tests/, en clases que heredan de TestCase. Cada método marcado con #[Test] es una prueba, y las comprobaciones son los métodos assert...:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Carrito;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CarritoTest extends TestCase
{
#[Test]
public function un_carrito_vacio_suma_cero(): void
{
$this->assertSame(0, (new Carrito())->total());
}
#[Test]
public function suma_precio_por_cantidad(): void
{
$carrito = new Carrito();
$carrito->agregar('Café', 1250, 2);
$carrito->agregar('Taza', 800);
$this->assertSame(3300, $carrito->total());
}
#[Test]
public function aplica_el_descuento_sin_decimales(): void
{
$carrito = new Carrito();
$carrito->agregar('Libro', 999);
$this->assertSame(899, $carrito->totalConDescuento(10));
}
#[Test]
public function rechaza_un_descuento_imposible(): void
{
$this->expectException(\InvalidArgumentException::class);
(new Carrito())->totalConDescuento(150);
}
}vendor/bin/phpunit tests --testdox
salidaPHPUnit 13.4.5 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.12 .... 4 / 4 (100%) Time: 00:00.004, Memory: 20.00 MB Carrito (App\Tests\Carrito) ✔ Un carrito vacio suma cero ✔ Suma precio por cantidad ✔ Aplica el descuento sin decimales ✔ Rechaza un descuento imposible OK (4 tests, 4 assertions)
Cada punto de la primera línea es una prueba superada, y --testdox convierte los nombres de los métodos en frases. assertSame compara con ===, así que el tipo también cuenta: es la que deberías usar casi siempre. Así se ve cuando una prueba falla; aquí cambiamos a propósito el resultado esperado de 899 a 900:
salida con errorPHPUnit 13.4.5 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.12 ..F. 4 / 4 (100%) Time: 00:00.006, Memory: 20.00 MB There was 1 failure: 1) App\Tests\CarritoTest::aplica_el_descuento_sin_decimales Failed asserting that 899 is identical to 900. /tmp/tienda/tests/CarritoTest.php:34 FAILURES! Tests: 4, Assertions: 4, Failures: 1. código de salida: 1
PHPUnit dice qué prueba falló, qué esperaba, qué obtuvo y en qué línea, y termina con un código de salida distinto de 0, que es lo que mira cualquier sistema de integración continua para impedir que el cambio llegue a producción.
PHPStan: encontrar fallos sin ejecutar
PHP solo descubre muchos errores al ejecutar la línea que falla. PHPStan lee el código y encuentra esos errores antes, como haría el compilador en otros lenguajes. Con nuestro proyecto, en el nivel más estricto:
vendor/bin/phpstan analyse src --level=max
salida [OK] No errors
El error que te ahorra: tres fallos que PHP no ve hasta que es tarde
Añadimos al proyecto esta clase, que tiene tres errores. Para PHP la sintaxis es correcta:
<?php
declare(strict_types=1);
namespace App;
final class Cupon
{
public function __construct(public int $importeCentimos) {}
}
final class Cupones
{
/** @var array<string, Cupon> */
private array $cupones;
public function __construct()
{
$this->cupones = ['BIENVENIDA' => new Cupon(500)];
}
public function aplicar(int $total, string $codigo): int
{
$cupon = $this->cupones[$codigo] ?? null;
return $total - $cupon->importeCentimos;
}
public function describir(string $codigo): string
{
return "Cupón $codigo: " . $this->aplicar(1000, $codgo);
}
}php -l src/Cupones.phpNo syntax errors detected in src/Cupones.php
vendor/bin/phpstan analyse src --level=max ------ -----------------------------------------------------------------------
Line Cupones.php
------ -----------------------------------------------------------------------
24 Cannot access property $importeCentimos on App\Cupon|null.
🪪 property.nonObject
29 Parameter #2 $codigo of method App\Cupones::aplicar() expects string,
mixed given.
🪪 argument.type
29 Undefined variable: $codgo
🪪 variable.undefined
------ -----------------------------------------------------------------------
[ERROR] Found 3 errorsUn cupón que no existe deja $cupon en null, y acceder a su importe fallaría en producción el día que alguien escriba mal un código. La variable $codgo está mal escrita: es el aviso de la lección 1, pero detectado sin ejecutar nada. Y como no existe, su tipo es mixed, de ahí el segundo error. Empieza con un nivel bajo (--level=5) en proyectos antiguos y súbelo poco a poco.
Ojo con la seguridad: dependencias con fallos conocidos
Tu código puede ser impecable y tu aplicación vulnerable por una biblioteca. Composer lo vigila de dos formas. La primera, desde la versión 2.9: se niega a instalar versiones con fallos de seguridad publicados. Esto pasa al pedir una versión antigua de Guzzle, la biblioteca HTTP más usada de PHP:
composer require guzzlehttp/guzzle:7.4.0./composer.json has been created Running composer update guzzlehttp/guzzle Loading composer repositories with package information Updating dependencies Your requirements could not be resolved to an installable set of packages. Problem 1 ...
La segunda es composer audit, que revisa lo que ya tienes instalado. Para enseñarla, desactivamos el bloqueo solo en un proyecto de prueba (composer config policy.advisories.block false, que nunca deberías usar en uno real) e instalamos esa versión:
composer audit --format=plainFound 14 security vulnerability advisories affecting 1 package: Package: guzzlehttp/guzzle Severity: high Advisory ID: PKSA-gcrk-3vtt-1r14 CVE: CVE-2026-69246 Title: Guzzle: Noncanonical host can bypass host-based checks URL: https://github.com/advisories/GHSA-v5mv-p594-2x33 Affected versions: >=8.0.0,<8.0.1|<7.15.2 Reported at: 2026-08-03T21:07:26+00:00 -------- Package: guzzlehttp/guzzle Severity: medium Advisory ID: PKSA-cnw1-2ytm-cgr8 CVE: CVE-2026-69245 Title: Guzzle: Noncanonical cookie domain keeps subdomain scope URL: https://github.com/advisories/GHSA-f7vp-7xgx-4w4r Affected versions: >=8.0.0,<8.0.1|<7.15.2 Reported at: 2026-08-03T21:05:26+00:00 -------- ...
Catorce avisos, uno de gravedad alta. En nuestra tienda, en cambio, composer audit no encuentra nada y termina con código 0. Tres costumbres que evitan sustos: ejecutar composer audit en la integración continua, para que falle si aparece un aviso nuevo; actualizar las dependencias con regularidad, no solo cuando hay problemas; y revisar bien el nombre de un paquete antes de instalarlo, porque hay paquetes maliciosos con nombres casi iguales a los populares.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Agrupar clases | namespace App\Facturacion; | después de declare |
| Usar una clase de otro sitio | use Ramsey\Uuid\Uuid; | as para renombrar |
| Cargar las clases solas | require 'vendor/autoload.php'; | PSR-4 en composer.json |
| Añadir una biblioteca | composer require x/y | composer.lock va al repositorio |
| Probar | vendor/bin/phpunit tests | assertSame compara con === |
| Encontrar fallos sin ejecutar | vendor/bin/phpstan analyse src | sube el nivel poco a poco |
| Vigilar las dependencias | composer audit | en cada cambio, automáticamente |
Retos
Reto 1. ¿En qué archivo buscará el autoloader la clase App\Pagos\Tarjeta, con la regla "App\\": "src/"? ¿Y App\Tests\Pagos\TarjetaTest?
Ver la solución
src/Pagos/Tarjeta.php y tests/Pagos/TarjetaTest.php. El prefijo más largo que coincide gana: App\Tests\ apunta a tests/. Las mayúsculas importan, porque en Linux Pagos y pagos son carpetas distintas.
Reto 2. Escribe la prueba que falta: que agregar con cantidad 0 lance una InvalidArgumentException.
Ver la solución
#[Test]
public function rechaza_una_cantidad_cero(): void
{
$this->expectException(\InvalidArgumentException::class);
(new Carrito())->agregar('Café', 1250, 0);
}expectException va antes de la línea que debe fallar: si la pones después, la excepción ya habrá terminado la prueba. El error lo lanza el constructor de Linea, y la prueba pasa porque la excepción atraviesa agregar.
Reto 3. Un compañero sube a producción el proyecto haciendo composer update en el servidor. ¿Qué puede salir mal y qué debería hacer?
Ver la solución
composer update instala las versiones más nuevas que permita composer.json, que pueden no ser las que se probaron. El servidor acabaría con otro código que el que pasó las pruebas. Lo correcto es composer install --no-dev, que instala exactamente lo de composer.lock y deja fuera PHPUnit y PHPStan. Las actualizaciones se hacen en desarrollo, se prueban y se suben con el composer.lock nuevo.
Todo el código de esta lección se ejecutó con PHP 8.5.12 en un contenedor limpio; el proyecto de ejemplo se montó con Composer 2.10.3, PHPUnit 13.4.5, PHPStan 2.3.3 y ramsey/uuid 4.9.4, descargados de Packagist en el momento. Las salidas están copiadas de esa ejecución.
