Ir al contenido principal
Laravel, shipping fast.

Capítulo 3

El proveedor detrás de una interfaz

Julian Beaujardin

El Capítulo 2 terminó con un controlador que llama a Licenses::all() y a Licenses::create() y no sabe nada más. Este capítulo construye lo que hay detrás de esas dos llamadas.

En la mayoría de las aplicaciones de Laravel la respuesta sería corta: un modelo de Eloquent. La lista sería una consulta, los casts te darían los tipos, y habrías terminado. Hazlo así cuando puedas. Un modelo, sus casts y un Resource son la convención, y nada de este capítulo la mejora.

La License API no puede. Sus licencias viven en Statamic, detrás de una API HTTP. Así que necesita tres cosas que un modelo le habría dado gratis: un objeto tipado que contenga una licencia, una clase que sepa hablar con el proveedor y un nombre al que el resto de la aplicación pueda llamar sin saber qué proveedor hay detrás.

Las dos primeras viven en una carpeta, app/Services/License. La tercera es una facade, en app/Facades. Son las únicas dos carpetas de esta API que ningún comando de Artisan crea.

Un objeto tipado para los datos del proveedor

Una respuesta HTTP es un array con lo que el proveedor haya decidido enviar. Pasa ese array de mano en mano y cada capa tiene que fiarse de claves que no puede ver. Una errata en el nombre de una clave sobrevive hasta producción, y tu editor no puede autocompletar lo que no conoce.

Por eso lo primero que le ocurre a una respuesta del proveedor es que se convierte en un objeto:

// app/Services/License/License.php
readonly class License
{
    /**
     * @param  array<int, string>  $domains
     */
    public function __construct(
        public string $key,
        public string $name,
        public array $domains,
        public string $createdAt,
    ) {}
}

Contiene datos y no tiene efectos secundarios. Su único método, más abajo, lo construye a partir del array del proveedor. readonly significa que una vez que existe no cambia: es una instantánea de lo que dijo el proveedor.

createdAt se queda como cadena. Es tentador convertirlo aquí en una fecha, porque ya tienes el valor entre las manos. Pero el trabajo de este objeto es decir lo que envió el proveedor, no interpretarlo. Convierte donde necesites una fecha, y el objeto seguirá siendo un registro fiel de la respuesta.

Quizá conozcas este patrón como data transfer object. Evito ese nombre en el código por una razón: invita a crear una capa. Un proyecto que tiene clases DTO pronto tiene una por cada petición y por cada respuesta. Esta API tiene exactamente una, puesta donde estaría un modelo si pudiera haberlo.

Donde el array se convierte en objeto

La clase también es dueña de la conversión, y la hace con una herramienta que ya conoces. La carga que envía un proveedor es una entrada, y Laravel tiene un validador para las entradas:

// app/Services/License/License.php
public static function fromProvider(array $data): self
{
    $validator = Validator::make($data, [
        'key' => ['required', 'string'],
        'name' => ['required', 'string'],
        'domains' => ['nullable', 'array'],
        'domains.*' => ['string'],
        'created_at' => ['required', 'string'],
    ]);

    if ($validator->fails()) {
        throw new MalformedProviderResponse(
            $validator->errors()->keys(),
        );
    }

    return new self(
        key: $data['key'],
        name: $data['name'],
        domains: $data['domains'] ?? [],
        createdAt: $data['created_at'],
    );
}

Aquí es donde la frontera se gana el sueldo. Que falte domains, o que venga en null, no es problema, y se convierte en un array vacío, porque una licencia sin dominios es válida. Que falte key sí es un problema, y el método lo dice bien alto, en el borde, en lugar de dejar que un null viaje tres capas hacia dentro y falle en algún lugar incapaz de explicarse.

Dos detalles importan aquí.

La excepción es de la propia aplicación, no del validador. Si de aquí escapara una ValidationException, Laravel la renderizaría como un 422, y al consumidor se le diría que su petición era inválida cuando la culpa es del proveedor. El Capítulo 7 le da a MalformedProviderResponse el estado que merece.

Y lleva los nombres de los campos que fallaron, no sus valores. La respuesta de un proveedor son datos ajenos, y no tienen por qué estar en tus logs.

No necesita red ni base de datos, lo que lo convierte en lo más barato de probar de todo el proyecto:

// tests/Unit/LicenseTest.php
it('accepts a license with no domains', function () {
    $license = License::fromProvider([
        'key' => 'lic_test',
        'name' => 'Test License',
        'created_at' => '2026-02-18T10:00:00Z',
    ]);

    expect($license->domains)->toBe([]);
});

it('rejects a license without a key', function () {
    License::fromProvider(['name' => 'Test License']);
})->throws(MalformedProviderResponse::class);

El validador es una facade, así que incluso este test necesita la aplicación arrancada. El tests/Pest.php del Capítulo 12 se encarga de eso tanto para tests/Unit como para tests/Feature.

Cuando el proveedor cambie el formato de su respuesta, y lo hará, este método es lo único que tendrás que actualizar.

Modelo, objeto tipado, Resource

La gente confunde estos tres, así que aquí va la diferencia en un solo lugar.

Un modelo habla con tu base de datos. Un objeto tipado como License transporta datos que vinieron de algún sitio que no es tu base de datos. Un Resource decide lo que ve el mundo exterior.

El objeto tipado es tu contrato interno: todo lo que sabemos de una licencia. El Resource es tu contrato externo: lo que estamos dispuestos a mostrar. Si tienes un modelo, no necesitas un objeto tipado delante de él. Si no tienes un modelo, el objeto tipado es lo que ocupa su lugar.

No se pudo cargar el audio. Inténtalo de nuevo en un momento.