Ir al contenido principal
Laravel, shipping fast.
Capitulo 2 · Arquitectura central

El patron Manager-Facade-Driver

Julian Beaujardin

¡Mi favorito! El patrón Manager-Facade-Driver es algo que ves por todo Laravel: en caché, en sesiones, en colas, en lo que quieras. Y una vez lo entiendes, podrás aplicarlo a tu propio código.

La idea básica: quieres soportar varias implementaciones de la misma cosa. Distintos proveedores de licencias. Distintas pasarelas de pago. Distintos backends de almacenamiento. En lugar de esparcir lógica condicional por toda tu base de código, creas un sistema extensible.

Lo que esto significa en la práctica: puedes soportar Statamic CMS hoy y cambiar a Filament u October CMS más adelante con sólo un driver nuevo, sin cambiar código existente. Añadir una implementación nueva requiere cero modificaciones al sistema actual. Tu aplicación crece por adición, no por modificación. Eso es potente.

Más allá de la flexibilidad, este patrón te da código limpio y desacoplado. A tu controlador le da igual qué proveedor está atendiendo la petición: llama a una interfaz consistente. Simulas el driver entero en las pruebas sin tocar tu código de producción. La lógica de negocio se mantiene pura y testable, separada de los detalles de implementación de cualquier proveedor concreto.

Paso 1: define el contrato (interfaz)

Antes de escribir un solo driver, hay que acordar cómo es un driver. Para eso está una interfaz. Es un contrato, una promesa que dice «cualquier proveedor de licencias que implemente esta interfaz tendrá exactamente estos métodos con exactamente estas firmas». Todavía no estás escribiendo la implementación: estás definiendo la forma.

En esta API establecemos que todo proveedor de licencias debe soportar tres operaciones. Primero, licenses() devuelve una colección de todas las licencias disponibles. Segundo, addLicense() crea una licencia nueva con nombre y dominio. Tercero, deleteLicense() elimina una licencia por su clave. Si un proveedor no puede hacer esas tres cosas, no debería implementar esta interfaz. Ese es el punto: la interfaz impone ese contrato mínimo.

// app/Services/License/LicenseContract.php
interface LicenseContract
{
    public function licenses(): Collection;
    public function addLicense(string $name, string $domain): array;
    public function deleteLicense(string $key): ?array;
}

Lo genial de este enfoque: a tu aplicación le da igual qué proveedor estás usando de verdad. No necesita saber si hablas con Statamic, Filament, October CMS o un objeto simulado en tus pruebas. Tu código sólo dice «dame algo que implemente LicenseContract» y luego llama a los métodos que sabe que estarán ahí. Eso es polimorfismo: la capacidad de trabajar con muchas implementaciones distintas a través de una única interfaz.

Paso 2: crea implementaciones concretas (drivers)

Este paso es donde el contrato se hace real. La interfaz define qué hay que hacer, pero cada proveedor tiene su propia forma de API, su esquema de autenticación, sus endpoints y sus manías. Un driver aísla esos detalles en un sitio para que el resto de tu base de código no tenga que enterarse nunca. En lugar de llenar controladores y servicios de condicionales como «si es Statamic llama a /sites; si es Filament llama a /licenses», mantienes la lógica enfocada: un driver por proveedor, una responsabilidad por clase.

// app/Http/Integrations/Statamic/StatamicAPI.php
final readonly class StatamicAPI implements LicenseContract
{
    use SendsRequests;

    public function __construct(
        protected PendingRequest $request,
    ) {}

    public function licenses(): Collection
    {
        return $this->send('GET', '/sites')->collect('data');
    }

    public function addLicense(string $name, string $domain): array
    {
        return $this->send('POST', '/sites', [
            'name' => trim($name),
            'domain' => trim($domain),
        ])->json('data') ?? [];
    }

    public function deleteLicense(string $key): ?array
    {
        return $this->send('DELETE', "/sites/{$key}")->json('data');
    }
}

Ahora tienes un driver concreto que implementa la interfaz. La clase StatamicAPI sabe exactamente cómo hablar con la API REST de Statamic. Maneja la autenticación, sabe a qué endpoints llamar, sabe cómo analizar respuestas y lo transforma todo al formato consistente que espera el resto de tu aplicación. Es una implementación autocontenida del contrato.

Inyectamos un PendingRequest en el constructor. Es el cliente HTTP de Laravel, preconfigurado con autenticación. Inyectarlo en lugar de escribir las llamadas HTTP a fuego te permite meter un simulado en las pruebas. Tus pruebas corren al instante sin hacer llamadas de red reales, y controlas exactamente qué respuestas recibe el driver.

Los drivers existen por separado porque los proveedores tienen APIs radicalmente distintas. Statamic puede usar endpoints /sites con autenticación por token. Otro CMS puede usar /licenses con OAuth. Un tercero puede usar GraphQL. Separándolos, cada driver sólo se ocupa de su proveedor. Sin condicionales comprobando qué proveedor usas. Un driver, un proveedor, una implementación enfocada.

/** @property-read PendingRequest $request */
trait SendsRequests
{
    /**
     * @param  array<array-key, mixed>  $data
     *
     * @throws RequestException|ConnectionException|InvalidArgumentException
     */
    protected function send(string $method, string $url, array $data = []): Response
    {
        $maxAttempts = max(1, $this->intConfig('throttle_retry.max_attempts', 3));
        $attempt = 0;

        while (true) {
            $attempt++;
            $response = $this->dispatchRequest($method, $url, $data);

            if (! $response->failed()) {
                return $response;
            }

            // A 429 is refused before the upstream does any work, so retrying
            // it is side-effect-free regardless of the HTTP method.
            if ($response->status() === 429 && $attempt < $maxAttempts) {
                $this->backoffForThrottle($response, $attempt);

                continue;
            }

            throw new RequestException(response: $response);
        }
    }

    /**
     * @param  array<array-key, mixed>  $data
     */
    private function dispatchRequest(string $method, string $url, array $data): Response
    {
        return match ($method) {
            'GET' => $this->request->get($url, $data),
            'POST' => $this->request->post($url, $data),
            'PUT' => $this->request->put($url, $data),
            'PATCH' => $this->request->patch($url, $data),
            'DELETE' => $this->request->delete($url, $data),
            default => throw new InvalidArgumentException(
                message: "Invalid HTTP method '{$method}'. Expected one of: GET, POST, PUT, PATCH, DELETE",
            ),
        };
    }

    /**
     * Honour the upstream's Retry-After when it gives one, else back off
     * exponentially — clamped, so a single call cannot outlast the worker's
     * job timeout and take the whole job down with it.
     */
    private function backoffForThrottle(Response $response, int $attempt): void
    {
        $base = max(0, $this->intConfig('throttle_retry.base_delay_ms', 500));
        $max = max(0, $this->intConfig('throttle_retry.max_delay_ms', 5000));

        $retryAfter = $response->header('Retry-After');

        $delayMs = is_numeric($retryAfter)
            ? (int) ((float) $retryAfter * 1000)
            : $base << ($attempt - 1);

        $delayMs = max(0, min($delayMs, $max));

        if ($delayMs > 0) {
            usleep($delayMs * 1000);
        }
    }
}

Hay tres decisiones en ese trait que merece la pena nombrar.

Vive en un paquete compartido, no en el servicio. Todo driver de todo servicio que envíe una petición HTTP obtiene el mismo comportamiento de reintento sin que nadie tenga que acordarse de añadirlo —que es la única forma de que una preocupación transversal siga siendo realmente transversal.

Reintenta sólo en 429. Un 429 significa que el servicio externo rechazó la petición antes de hacer trabajo alguno, así que repetirla es seguro sea cual sea el método. Ese razonamiento no se extiende a un 500, donde no tienes ni idea de hasta dónde llegó el otro lado; esos lanzan de inmediato.

Y la espera está acotada. Un servicio que devuelve Retry-After: 3600 le está pidiendo a tu worker que duerma una hora dentro de un job que morirá mucho antes. Respeta la pista, pero nunca más allá de tu propio tiempo de espera.

Paso 3: crea el manager (fabrica y enrutador)

Aquí es donde la extensibilidad se paga de verdad. El Manager es una fábrica inteligente que sabe crear drivers bajo demanda. Es también un enrutador que sabe qué driver usar y cuándo. Cuando tu aplicación dice «dame un proveedor de licencias», el Manager responde «un momento, déjame ver qué necesitas y te lo creo». Es el orquestador entre tu código y las implementaciones concretas.

// app/Services/License/LicenseManager.php
class LicenseManager extends Manager
{
    protected function createStatamicDriver(): LicenseContract
    {
        return new StatamicAPI(
            Http::withToken(config('services.license.drivers.statamic.token')),
        );
    }

    public function getDefaultDriver(): string
    {
        return config('services.license.default');
    }

    public function driver(string $driver = null): LicenseContract
    {
        return parent::driver($driver);
    }
}

El método createStatamicDriver() es donde se construye el driver. La clase Manager de Laravel usa convención: cuando pides el driver «statamic», llama automáticamente a createStatamicDriver(). Sin registro, sin ficheros de mapeo. Sigue el patrón de nombres y Laravel lo encuentra. Cogemos el token de la configuración y le pasamos al driver un cliente HTTP ya configurado.

El método getDefaultDriver() devuelve qué driver usar si no pides otro explícitamente. Así que cuando llamas a LicenseFacade::licenses() sin especificar, el Manager mira tu configuración y dice «vale, el predeterminado es Statamic, usaré ese». Esto mantiene flexible tu aplicación: puedes cambiar el driver por defecto cambiando una variable de entorno.

Fíjate en que sacamos las credenciales de config(...), no las escribimos a fuego. En producción vienen de un token real de tu entorno. En pruebas, de un token de prueba. El Manager pasa la configuración al driver pero nunca sabe ni le importa de dónde salió. Esa separación es crucial.

Por debajo de todo esto está la clase base Manager de Laravel, que se encarga de cachear instancias de driver para que no crees nuevas repetidamente, de enrutar entre drivers si tienes varios y de llamar a tus métodos creadores. No tienes que implementar toda esa fontanería: tú aportas el método createXxxDriver() y confías en que Laravel hace el resto.

Paso 4: crea la facade (acceso comodo)

// app/Facades/LicenseFacade.php
class LicenseFacade extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return LicenseManager::class;
    }
}

Una Facade es un atajo cómodo. En lugar de meter la mano en el contenedor de servicios y llamar métodos sobre el Manager, llamas métodos estáticos sobre la Facade. Además hace el código más legible. Compara:

// Without facade - verbose
app(LicenseManager::class)->driver()->licenses();

// With facade - concise
LicenseFacade::licenses();

La Facade es azúcar sintáctico. Por debajo, LicenseFacade::licenses() llama a app(LicenseManager::class)->licenses(). Pero se lee mejor.

getFacadeAccessor(): este método le dice a Laravel «cuando alguien llame a un método en esta Facade, reenvíalo a la instancia de LicenseManager del contenedor». Es la conexión entre la Facade y el Manager real.

Las llamadas estáticas están permitidas: las Facades te dejan llamar métodos estáticamente aunque en realidad sean métodos de instancia del Manager. Se hace mediante el método mágico __callStatic() de PHP. Es maravillosamente cómodo.

Paso 5: registralo en el service provider

Registrar el Manager en un service provider es necesario porque Laravel no sabe automáticamente cómo construir tu clase Manager personalizada. Cuando tu controlador necesite un LicenseManager, Laravel debe saber cómo crearlo. El contenedor de servicios gestiona la instanciación y el ciclo de vida, así que registras ahí el Manager. Sin registro, Laravel no puede resolver la clase.

Para esta API usamos un LicenseServiceProvider dedicado para mantener las cosas organizadas. Es más limpio que volcarlo todo en el AppServiceProvider:

// app/Providers/LicenseServiceProvider.php
final class LicenseServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(LicenseManager::class);
    }

    public function provides(): array
    {
        return [LicenseManager::class];
    }
}

Y luego lo registras en bootstrap/providers.php:

// bootstrap/providers.php
return [
    \App\Providers\AppServiceProvider::class,
    \App\Providers\LicenseServiceProvider::class,
];

Comportamiento de register(). El método le dice al contenedor de Laravel «cuando alguien pida un LicenseManager, crea una instancia y consérvala».

Comportamiento de singleton. singleton() significa que Laravel crea el Manager una vez y reutiliza la misma instancia en todas partes. Esto es importante porque el Manager cachea instancias de driver. Si crearas un Manager nuevo cada vez, perderías la caché.

El método provides(): le dice a Laravel qué servicios ofrece este provider. Es opcional pero útil para depurar y optimizar.

Provider separado. Al crear un LicenseServiceProvider dedicado, mantenemos organizado el sistema de licencias. A medida que tu API crezca y necesites varios managers (pasarelas de pago, servicios de notificación, etc.), cada uno tendrá el suyo.

Momento de ejecución. Los providers se registran al arrancar la aplicación. register() se llama antes de que nada más use el Manager, garantizando que esté listo.

Paso 6: usalo en tu controlador

Aquí se junta todo. Tu controlador es simple porque el trabajo ocurre en otro sitio. Cada método tiene un trabajo: coger la petición, usar la facade para obtener datos, transformarlos si hace falta y devolver una respuesta.

Mira el método show(). Llama a LicenseFacade::licenses() y recibe datos. El mapper los transforma en DTOs tipados. El Resource los transforma a JSON. El Response Wrapper lo empaqueta. Cinco líneas clarísimas sobre lo que está pasando. Sin switch averiguando qué driver usar. Sin condicionales comprobando configuraciones. Sin llamadas a API enterradas en la lógica.

// app/Http/Controllers/LicenseController.php
public function show(): Responsable
{
    return new CollectionResponse(
        data: LicenseResource::collection(
            resource: LicenseDTOMapper::toDTOCollection(
                licenses: LicenseFacade::licenses(),
            ),
        )
    );
}

Tu controlador nunca tiene que preocuparse de hacer malabares con arrays o claves ausentes. Sólo se ocupa de orquestar. Esto es lo que pasa realmente cuando llega una petición:

1. GET /api/licenses arrives
2. Controller's show() method runs
3. LicenseFacade::licenses() calls external API
   Returns: ['key' => 'lic_123', 'name' => 'License', ...]
4. LicenseDTOMapper::toDTOCollection() converts it
   Returns: [LicenseDTO(...), LicenseDTO(...), ...]
   Type: array of LicenseDTO
5. LicenseResource::collection() transforms each DTO
   Only exposes whitelisted fields, computes values
6. CollectionResponse packages it
   Adds status code, headers, JSON structure
7. Client gets:
   {
     "items": [
       { "key": "lic_123", "name": "License", "domains": ["example.com"], ... }
     ]
   }

Cada paso y cada transformación es explícita y testable.

El método create() es igual de obvio. Recibe una petición validada (gracias, FormRequest), pasa los datos a la facade, mapea y transforma el resultado, y lo devuelve con un 201. Una petición, una transformación, una respuesta. Alguien junior lo lee una vez y lo entiende del todo.

// Good: Thin controller delegating work
public function create(CreateLicenseRequest $request): Responsable
{
    return new ModelResponse(
        data: new LicenseResource(
            resource: LicenseDTOMapper::toDTO(
                LicenseFacade::addLicense(
                    name: $request->validated('name'),
                    domain: $request->validated('domain'),
                )
            ),
        ),
        status: Response::HTTP_CREATED, //201
    );
}

¿Ves el patrón? Cada método es una tubería recta de la entrada a la salida: responsable → resource → mapper → facade.

El método destroy() hace algo interesante: difiere el borrado real al segundo plano y devuelve de inmediato un 202 Accepted. Así se manejan con elegancia las operaciones asíncronas. El cliente sabe que el borrado está en cola y ocurrirá pronto, y no tiene que esperar a que responda la API externa.

El código de estado se vuelve importante aquí. Fíjate en que pasamos explícitamente Response::HTTP_ACCEPTED (202). Es necesario porque MessageResponse devuelve 200 por defecto, y necesitamos un código distinto para señalar que la operación se está procesando de forma asíncrona en lugar de haberse completado.

// app/Http/Controllers/LicenseController.php
public function destroy(string $key): Responsable
{
    defer(
        callback: fn () => LicenseFacade::deleteLicense(
            key: $key,
        ),
    );

    return new MessageResponse(
        message: Lang::get('messages.delete_queued'),
        status: Response::HTTP_ACCEPTED, //202
    );
}

Devuelve HTTP 202 (Accepted) para señalar «lo he recibido, se está procesando». Devuelve HTTP 200 (OK) para señalar «aquí tienes tu confirmación». Quien consume tu API lee el código y entiende el contrato. Sabe que 202 significa «espera un webhook o consulta el estado». Sabe que 200 significa «está hecho».

Hay algo notable en estos tres métodos: no contienen ni una sola variable $. Sin asignaciones intermedias. Sin sitios temporales donde guardar datos en tránsito. La lógica fluye directamente de la entrada a la salida, con llamadas anidadas dentro de llamadas, y cada valor de retorno canalizado directamente al siguiente parámetro.

Este patrón es posible porque los argumentos con nombre y la inyección de dependencias funcionan de maravilla juntos. Como cada capa devuelve exactamente lo que la siguiente necesita, no hacen falta variables temporales para guardar resultados entre pasos. Cada variable es otra cosa que tu cerebro tiene que seguir, y eso genera carga cognitiva.

Cuando compones las llamadas directamente, eliminas esa carga.

Lo lees de arriba abajo y el flujo de datos es obvio. Sin estado intermedio que seguir. Sin mutaciones escondidas en variables. Los datos fluyen por la tubería, cada paso los transforma, nada se modifica tras crearse. Este estilo de tubería inmutable hace el código más difícil de equivocar. No puedes reutilizar una variable por accidente. No puedes meter algo en una variable, olvidarte y pasar sin querer el valor antiguo.

La ausencia de $ no es una excentricidad: es una señal arquitectónica. Indica que cada componente hace una cosa bien, transformando entrada en salida sin estado oculto ni efectos secundarios. Significa que probar es más simple porque sabes que no puede pasar nada inesperado. Significa que refactorizar es más seguro porque no hay dependencias de estado que romper.

Y esto es lo que lo hace genuinamente potente: tu controlador no sabe qué proveedor está usando. Le da igual si hablas con Statamic, con Filament o con un simulado. Sólo llama a la facade. La facade sabe qué driver usar según la configuración. Todo lo de debajo está completamente desacoplado de tu capa HTTP, lo que significa que en pruebas puedes inyectar un driver simulado y tu controlador se comporta exactamente igual.

Paso 7: cambia de proveedor por configuracion

Aquí es donde toda la arquitectura se paga. Tu fichero .env tiene un único ajuste:

// .env file
LICENSE_DRIVER=statamic

Cambia esa línea y la aplicación entera cambia de proveedor. Una variable de entorno lo controla. Sin cambios de código, sin redesplegar controladores. Actualizas .env y ya está. Tu equipo no tiene que tocar el Controller, la Facade ni el registro del Manager.

// config/services.php
'license' => [
    'default' => env('LICENSE_DRIVER', 'statamic'),
    'drivers' => [
        'statamic' => [
            'url' => env('STATAMIC_API_URL', 'https://statamic.com/api/v1'),
            'token' => env('STATAMIC_API_TOKEN', null),
        ],
    ],
],

Piensa en lo que esto significa de verdad. En desarrollo pones LICENSE_DRIVER=local y obtienes respuestas simuladas al instante, sin llamadas a API. Tus pruebas corren rápido, tu ciclo de retroalimentación es corto. En preproducción cambias a LICENSE_DRIVER=test y das contra el entorno de pruebas. En producción es LICENSE_DRIVER=statamic con credenciales reales de tus variables de entorno.

Tu código de controlador no cambia. Tu service provider no cambia. Tu aplicación entera se ve idéntica en los tres entornos. La única diferencia es una línea del .env. Ese es todo el propósito de esta arquitectura.

Si necesitas soportar un proveedor nuevo para la semana que viene, escribes una clase driver nueva, creas un método createNewProviderDriver() en el Manager y cambias el .env. Sin condicionales esparcidos por tu código. Sin riesgo de usar por accidente el proveedor equivocado en el sitio equivocado.

Cuando tengas que migrar de proveedor de licencias con una fecha encima, te evitas refactorizar la base de código entera. Escribes una clase driver enfocada y cambias una variable de entorno. Todo sigue funcionando, las pruebas siguen pasando y la API sigue en pie.

Todo junto

HTTP Request (e.g., POST /api/license with Bearer token)
Router (routes/api.php matches the request)
Controller Action (LicenseController::create)
FormRequest Validation (CreateLicenseRequest checks input)
Service/Facade (LicenseFacade calls API or database)
External API or Database (fetch actual data)
Mapper (convert to DTO)
Resource (DTO → JSON)
Response Wrapper (format in standard structure)
JSON Response to Client

Cada capa tiene una única responsabilidad. Cada capa es testable. Cada capa es reemplazable. Estos son los cimientos sobre los que se construye todo lo demás.