Ir al contenido principal

Devlog · Fase 0 · 5/7

Los cinco módulos Laravel de aubia.dev

El backend de aubia.dev agrupa el código por responsabilidad. Actions, Queries y DTO ayudan a seguir un cambio, pero las carpetas por sí solas no controlan las dependencias.

Publicado el 10 de agosto de 2026Actualizado el 15 de septiembre de 20266 min de lectura
  • laravel
  • architecture
  • octane

El controlador de inscripción, su validación y los datos enviados al cloud se encuentran en app/Modules/Waitlist/. Para seguir un envío, no salgo de esa carpeta hasta la llamada HTTP saliente.

Trabajo con agentes de IA y reviso sus pull requests. Agrupar los archivos por responsabilidad me da un punto de partida: un cambio en el formulario me lleva a Waitlist, un cambio en el sitemap a Seo. Sigo necesitando los imports y el diff para entender a qué afecta el cambio.

Agrupar el código por responsabilidad

Laravel ofrece una estructura de partida sin exigir módulos. En este sitio público utilizo cinco carpetas dentro de app/Modules/:

  • Blog lee los artículos Markdown, los renderiza en HTML y genera el feed RSS.
  • Cloud agrupa el transporte HTTP hacia api.aubia.cloud en CloudApiClient.
  • Locale selecciona el idioma y construye las URL localizadas.
  • Seo genera el sitemap a partir de las páginas públicas y los artículos publicados.
  • Waitlist transmite las solicitudes de inscripción y obtiene el número de inscripciones confirmadas.

Cada módulo contiene las subcarpetas que necesita. Cloud solo tiene una clase en su raíz. Blog también incluye comandos Artisan y adaptadores para el motor Markdown.

Mapa simplificado de los cinco módulos: Locale, Seo, Waitlist, Blog y Cloud. Un flujo conecta Waitlist con Cloud y después con api.aubia.cloud.

Este mapa muestra los módulos y su pasarela HTTP. No representa todas las dependencias ni todas las subcarpetas: Blog y Waitlist también tienen una carpeta Queries/.

Actions y Queries

Una Action ejecuta una operación mediante un método de instancia llamado execute(). SubmitWaitlistEmailAction orquesta la inscripción; BuildSitemapAction genera el XML del sitemap. Una Query expone una lectura mediante run(): ListBlogPostsQuery devuelve los artículos publicados, FindBlogPostQuery busca un artículo y FetchWaitlistCountQuery lee el contador remoto.

La distinción no depende de dónde se almacenen los datos. El blog lee archivos locales, mientras que el contador consulta una API. Ambas lecturas son Queries.

Los artículos de producción se preparan durante el build. CompileBlogAction llama a ParseBlogPostAction, que valida las fuentes y orquesta el renderizado Markdown, el resaltado Phiki y la estimación del tiempo de lectura. ListBlogPostsQuery lee después el catálogo mediante ReadCompiledBlogQuery y filtra las fechas de publicación. El parsing con caché sigue disponible en local y en los tests; en producción, un catálogo ausente, inválido o desactualizado devuelve 503 sin recurrir al parsing.

Estos nombres y puntos de entrada son convenciones del repositorio. Laravel puede construir e inyectar clases concretas a partir de sus tipos; no hace falta ninguna biblioteca adicional de Actions.

Una dependencia visible en el constructor

La raíz / redirige a una URL localizada con una respuesta 302. ResolveLocaleRedirectAction recibe DetectLocaleAction en su constructor y le pasa los candidatos por orden de prioridad.

namespace App\Modules\Locale\Actions;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;

final readonly class ResolveLocaleRedirectAction
{
    public function __construct(private DetectLocaleAction $detectLocaleAction) {}

    public function execute(Request $request): string
    {
        return $this->detectLocaleAction->execute([
            $request->cookie(DetectLocaleAction::COOKIE_NAME),
            $this->detectLocaleAction->extractAcceptLanguage($request),
            Config::string('app.locale'),
        ])->value;
    }
}

Se elige el primer candidato reconocido: la cookie de preferencia, después el idioma extraído de la cabecera Accept-Language y por último la configuración. DetectLocaleAction devuelve un SupportedLocale, el enum de los seis idiomas disponibles. El acceso a ->value proporciona la cadena utilizada en la URL.

El middleware SetLocale llama a la misma Action añadiendo el segmento de URL al principio. La regla de selección es común, pero la lista de candidatos depende de quien la llama. La petición HTTP se pasa a execute(), sin guardarla en el constructor.

Los datos después de la validación

El formulario de inscripción pasa por una FormRequest, la clase de validación de Laravel. También comprueba las protecciones locales contra bots y después selecciona los campos que necesita WaitlistSubmissionData.

Este DTO, u objeto de transferencia de datos, contiene la dirección de correo electrónico, los tres valores de atribución UTM y la locale. Los campos de control contra bots no forman parte de sus propiedades, por lo que este objeto no los pasa a la Action de inscripción.

El método de salida enumera el payload destinado al cloud:

public function toCloudPayload(): array
{
    return [
        'email' => $this->email,
        'utm_source' => $this->utmSource,
        'utm_medium' => $this->utmMedium,
        'utm_campaign' => $this->utmCampaign,
        'locale' => $this->locale->value,
    ];
}

La locale sigue siendo un enum en el código PHP y se convierte en una cadena en la salida. Añadir una propiedad al DTO no la añade automáticamente al payload: hay que modificar este método.

El blog aplica el mismo principio a distintos destinos. BlogPostData proporciona un resumen sin HTML para el índice, el artículo completo para su página y un array de valores serializables para el catálogo compilado y la caché local. El DTO transporta esos resultados; una Action se encarga del renderizado Markdown.

La pasarela HTTP del módulo Cloud

Las llamadas de la aplicación a aubia.cloud pasan por CloudApiClient. Lo utilizan dos clases: la Action de envío y la Query del contador, ambas en Waitlist. El sitio público no tiene una base de datos local para las inscripciones.

La Action prepara el cuerpo JSON y su firma. El cliente HTTP envía los bytes recibidos y gestiona los tiempos de espera, los reintentos y los registros de fallos. Esos registros indican el tipo de error sin almacenar la dirección de correo electrónico ni el cuerpo enviado.

Un GET puede reintentarse después de un error de conexión o una respuesta 5xx, pero no después de una respuesta 4xx. El POST firmado solo se reintenta después de un error de conexión. Ese error no demuestra que el servicio remoto no haya procesado nada: puede faltar la respuesta aunque se haya recibido la petición.

CloudApiClient es una clase final sin propiedades de instancia. Sus tests utilizan Http::fake() para simular respuestas y errores de red mientras ejecutan el cliente real de la aplicación.

Lo que readonly no garantiza con Octane

Las Actions, las Queries y los DTO se declaran final readonly. final impide la herencia. readonly impide reasignar las propiedades después de su inicialización, pero no convierte en inmutable un objeto almacenado en ellas.

ListBlogPostsQuery conserva las dependencias recibidas en el constructor, pero no las colecciones de artículos. Los resultados de lectura permanecen en las variables locales de sus métodos. El catálogo compilado en disco es distinto de la caché Redis utilizada por el modo de parsing local.

Con Octane, Laravel permanece cargado entre peticiones. La palabra clave readonly no determina la duración de una instancia. El repositorio no registra estas clases como singletons; registrarlas como servicios compartidos exigiría revisar su estado y sus dependencias. La documentación de Octane describe los riesgos de conservar una petición HTTP en un servicio de una petición a la siguiente.

Las dependencias siguen necesitando revisión

El sitemap utiliza ListBlogPostsQuery para obtener los artículos publicados. Por tanto, un cambio en Blog puede modificar la salida de Seo, aunque la pull request no modifique ningún archivo de Seo.

Los tests de arquitectura comprueban los sufijos, las declaraciones final readonly y los métodos públicos execute() o run(). No prohíben los imports entre módulos. Un agente puede introducir una dependencia mal ubicada en una carpeta con el nombre correcto.

Esta estructura añade clases y obliga a pasar de un archivo a otro para seguir una operación. En un sitio público de este tamaño, eso tiene un coste real. Mantengo esta organización para encontrar la validación, la orquestación y los datos de salida en los mismos lugares. Durante la revisión, sigo teniendo que rastrear sus dependencias y comprobar el comportamiento con tests.

Esta obra se cuenta aquí, artículo a artículo. Lo que viene después depende de lo que usted diga de ella.

Únase a la lista de espera