Casi ningún proyecto decide de forma consciente dónde vive su lógica de negocio. Se va acumulando donde toca en cada momento y, cuando quieres darte cuenta, la respuesta a "dónde se hace esto" depende de quién lo escribió. Vamos a recorrer las tres opciones habituales, ver qué se rompe exactamente en cada una y qué gana una operación de negocio cuando tiene una clase para ella sola, incluido lo que cuesta. En AndorraDev trabajamos sobre todo con Laravel, así que los ejemplos van con su ecosistema, pero la discusión es la misma en Symfony, Rails o Django y el criterio se traslada entero.
El controlador no es el sitio, pero el modelo tampoco
Toda aplicación llega al mismo punto. Empiezas escribiendo la lógica en el controlador porque es donde entra la petición, y funciona durante unos meses. Después ese controlador tiene doce métodos, cada uno hace cinco cosas y nadie sabe cuál de ellas se ejecuta también desde otro sitio.
La reacción habitual es mover todo al modelo. Y ahí el problema cambia de forma pero no de tamaño: el modelo pasa a saber de correos, de facturación, de PDFs y de colas, cuando su trabajo era representar una fila de una tabla.
La tercera vía, las clases de servicio, es mejor. Pero tiene un agujero que se nota a los dos años.
Lo que falla en las tres respuestas habituales
En el controlador, la lógica queda atada a una petición HTTP. El día que necesitas ejecutar lo mismo desde un comando programado, desde una cola o desde otro punto de la aplicación, copias y pegas. A partir de ahí hay dos versiones de la misma regla de negocio y solo una se corrige cuando cambia.
En el modelo, cualquier método puede tocar cualquier cosa, así que el archivo crece sin techo. Y al ser un objeto que se instancia por todas partes, es imposible saber desde dónde se llama a qué.
En una clase de servicio el código queda razonablemente ordenado, pero no hay convención de invocación. Cada servicio decide si se instancia, se inyecta o es estático, y eso lo decide quien lo escribió aquel día. Y hay un segundo coste: cuando esa misma operación tiene que ejecutarse en segundo plano, escribes un Job aparte que repite la firma y reenvía la llamada. Dos clases para una sola operación.
Una operación de negocio es una clase con un método
La idea del patrón de acciones es simple: una operación, una clase, un método. Se llama igual desde donde sea, y el nombre de la clase dice qué hace.
Así se ve una real, de una plataforma legal que hemos construido:
class GenerateQrCodeAction extends Action
{
protected CaseModel $caseModel;
public function handle(int $size = 150): string
{
$url = route('cases.show', $this->caseModel->slug);
return (string) QrCode::size($size)
->style('round')
->margin(1)
->generate($url);
}
}
Es todo. No hereda de un controlador, no vive dentro del modelo y no necesita que nadie recuerde cómo se instancia.
La misma clase se ejecuta ahora o en segundo plano
Aquí está la diferencia que de verdad importa frente a una clase de servicio. La misma acción se ejecuta de las dos maneras sin escribir un Job aparte:
$this->file->action('resize_image')->run();
$this->file->action('auto_tag')->queue('default')->dispatch();
Ese fragmento es real, de Abodara, y está en el mismo método. El redimensionado se hace al momento porque el usuario está esperando a ver la imagen. El etiquetado automático, que llama a un modelo de visión y tarda, se va a la cola. Dos comportamientos, una sola clase, ninguna duplicación.
Los reintentos y el retardo se declaran en la propia acción, no en el punto de llamada:
class AutoTagAction extends Action
{
public File $file;
protected int $tries = 5;
protected ?int $delay = 30;
Registrar las acciones en el modelo que las usa
Una vez que las operaciones viven en clases sueltas aparece un problema nuevo: encontrarlas. Nada en el modelo dice qué se puede hacer con él, y esa información acaba repartida entre controladores y vistas. Declararla en el propio modelo la devuelve a un solo sitio:
protected array $actions = [
'generate_qr' => GenerateQrCodeAction::class,
'create_slot' => CreateCaseSlotAction::class,
'add_fee' => AddCaseFeeAction::class,
'create_proforma' => CreateProformaAction::class,
'send_email' => SendCaseEmailAction::class,
];
Y a partir de ahí, $case->action('send_email')->run([...]). El modelo no contiene la lógica, pero sí publica el catálogo de lo que se le puede hacer. Abrir ese archivo y leer la lista es la documentación más útil que vas a tener del dominio.
En Abodara hay un modelo File que registra dieciséis operaciones, desde generar variantes hasta aplicar una marca de agua. Ninguna vive dentro del modelo.
Declara siempre los valores por defecto en la firma de handle() y valida dentro. Así la acción documenta su propio contrato: quien la lee sabe qué espera y qué ocurre si algo no llega, sin tener que ir a buscar quién la invoca. Es lo que hace que la misma acción sirva igual desde un controlador, desde una cola o desde la herramienta de un agente de IA.
Lo que se gana cuando dos sitios distintos llaman a lo mismo
El caso que justifica el patrón entero apareció al montar el copiloto de IA de una plataforma legal. Hay una operación, redactar una cláusula a partir de una descripción, que se invoca desde dos lugares que no se parecen en nada: un componente de la interfaz y una herramienta del agente de IA.
Sin acciones, eso son dos implementaciones. Con una acción, son dos líneas casi idénticas llamando a la misma clase:
// Desde el componente de la interfaz
$result = DraftClauseAction::create()->actor(auth()->user())->run([...]);
// Desde la herramienta del agente
$result = DraftClauseAction::create()->actor($user)->run([...]);
La validación, la llamada al modelo de lenguaje y el guardado del borrador viven en un solo sitio. Cuando cambie la regla, cambia una vez.
Ese actor() tampoco es decorativo: registra quién ejecuta la operación, que en un producto con equipos y permisos es la diferencia entre poder auditar y no poder. En esa plataforma se usa en 41 llamadas.
Qué hay en el mercado y en qué se diferencia
La alternativa conocida en Laravel es lorisleiva/laravel-actions, y es un paquete sólido. La diferencia técnica es real y conviene entenderla, porque no van a lo mismo.
Ese paquete convierte una acción en muchas cosas a la vez: puede registrarse como controlador en una ruta, actuar de listener de eventos y de comando de artisan. Es más superficie, y para quien quiera esa unificación está bien resuelta.
Laractions, que es el que mantenemos y usamos, hace deliberadamente menos. Se centra en una sola pregunta, dónde vive una operación de negocio, y a cambio trae dos cosas que la otra no tiene: el registro declarativo en el modelo que veías arriba, y el concepto explícito de actor, con auditoría opcional en base de datos.
Si lo que buscas es que tus acciones sean también tus rutas y tus comandos, la elección es la otra. Si lo que quieres es sacar la lógica de negocio del controlador y del modelo dejando un catálogo legible por modelo, y poder ejecutarlo síncrono o encolado sin escribir nada más, este encaja mejor. Es la razón por la que lo escribimos: ese era el problema que teníamos.
Tres detalles que conviene saber antes
Ningún patrón sale gratis, y este cobra en sitios concretos que conviene conocer antes de reescribir medio proyecto con él. Ninguno es descalificante, pero los tres te van a aparecer.
Heredar consume tu única herencia. Una acción hace extends Action, así que esa clase ya no puede extender ninguna otra. En la práctica una acción no suele heredar de nada más, pero conviene saberlo.
El despacho a cola es deliberadamente simple. Encolar devuelve void, así que no tienes cadenas, lotes ni afterCommit. Para orquestaciones complejas de trabajos en segundo plano sigues necesitando los Jobs de Laravel; esto cubre el caso de "esto tarda, hazlo luego", que es el 90%.
La validación integrada cubre los parámetros del método. Lo que llega como propiedad de la acción lo validas tú donde te convenga. Merece la pena decidirlo al principio y ser consistente, porque mover un dato de un sitio al otro cambia quién lo comprueba.
Y si no trabajas con Laravel
El patrón no es de Laravel ni de PHP. En Symfony el equivalente natural son los message handlers de Messenger, que ya separan la operación de quien la invoca y te dan el mismo síncrono o asíncrono. En Node, un módulo por caso de uso con una única función exportada consigue lo mismo sin ninguna librería.
Lo que importa es el criterio: una operación de negocio debe poder invocarse igual desde una petición, desde una cola y desde una prueba, y su nombre debe decir qué hace. Si tu código cumple eso, da igual cómo lo llames.
Dónde está el código
El paquete está publicado en Packagist, el código en GitHub y la documentación del paquete recoge todas las opciones. Es gratis, y entre las dos plataformas que hemos mencionado suma 81 acciones en producción.
Si estás con una aplicación que ha crecido y ya no sabes dónde meter cada cosa, es exactamente el problema que trabajamos en aplicaciones web a medida y desarrollo Laravel. Cuéntanoslo y le echamos un ojo.