Composer, espacios de nombres y pruebas en PHP

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";

Probar en el editor →

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/.

Con la regla App apunta a src, la clase App Carrito se busca en src/Carrito.php, App Tienda Pedido en src/Tienda/Pedido.php, y App Tests CarritoTest en tests/CarritoTest.php por la regla de desarrolloAutocarga PSR-4: del nombre de la clase al archivo"App\\": "src/" en composer.jsonApp\Carritosrc/Carrito.phpApp\Tienda\Pedidosrc/Tienda/Pedido.phpApp\Tests\CarritoTesttests/CarritoTest.phpEl prefijo del espacio de nombres elige la carpeta; el resto, subcarpetas y archivo.

PSR-4 es el estándar que usan casi todos los proyectos PHP: un archivo por clase, con la ruta sacada de su nombre. Esquema: decodigo.com.

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.

OrdenQué haceCuándo
composer require x/yañade una dependencia y la instalaal empezar a usar una biblioteca
composer require --dev x/ydependencia solo para desarrollarpruebas, análisis, depuración
composer installinstala las versiones exactas de composer.lockal clonar el proyecto o desplegarlo
composer updatebusca versiones nuevas dentro de lo permitidoa propósito, revisando qué cambia
composer auditbusca fallos de seguridad publicadosa menudo; mejor, automático
composer outdatedlista lo que tiene versiones más nuevaspara planificar actualizaciones
Qué se sube al repositorio. 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 errors

Un 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.

Más en Los fallos de seguridad típicos de PHP.

Resumen

ParaEscribeRecuerda
Agrupar clasesnamespace App\Facturacion;después de declare
Usar una clase de otro sitiouse Ramsey\Uuid\Uuid;as para renombrar
Cargar las clases solasrequire 'vendor/autoload.php';PSR-4 en composer.json
Añadir una bibliotecacomposer require x/ycomposer.lock va al repositorio
Probarvendor/bin/phpunit testsassertSame compara con ===
Encontrar fallos sin ejecutarvendor/bin/phpstan analyse srcsube el nivel poco a poco
Vigilar las dependenciascomposer auditen 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.