Reestructuración de un controlador de Laravel utilizando Services, Events, Jobs, Actions y más


Victor Arana Flores

09 Aug 2022

Una de las principales preguntas de Laravel que escucho es "Cómo estructurar el proyecto". Si la reducimos, la mayor parte suena como "Si la lógica no debe estar en los controladores, entonces ¿dónde debemos ponerla?"

El problema es que no hay una única respuesta correcta a estas preguntas. Laravel te da la flexibilidad de elegir la estructura tú mismo, lo cual es tanto una bendición como una maldición. No encontrarás ninguna recomendación en los documentos oficiales de Laravel, así que vamos a intentar discutir varias opciones, basándonos en un ejemplo concreto.

Aviso: como no hay una única forma de estructurar el proyecto, este artículo estará lleno de notas secundarias, "y qué pasaría si" y párrafos similares. Te aconsejo que no te los saltes, y leas el artículo completo, para estar al tanto de todas las excepciones a las mejores prácticas.

Imagina que tienes un método Controller para registrar usuarios que hace muchas cosas:

public function store(Request $request)
{
    // 1. Validación
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);
 
    // 2. Crear usuario
    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);
 
    // 3. Subir el archivo de avatar y actualizar el usuario
    if ($request->hasFile('avatar')) {
        $avatar = $request->file('avatar')->store('avatars');
        $user->update(['avatar' => $avatar]);
    }
 
    // 4. Inicio de sesión
    Auth::login($user);
 
    // 5. Generar un voucher personal
    $voucher = Voucher::create([
        'code' => Str::random(8),
        'discount_percent' => 10,
        'user_id' => $user->id
    ]);
 
    // 6. Envíe ese voucher con un correo electrónico de bienvenida
    $user->notify(new NewUserWelcomeNotification($voucher->code));
 
    // 7. Notificar a los administradores sobre el nuevo usuario
    foreach (config('app.admin_emails') as $adminEmail) {
        Notification::route('mail', $adminEmail)
            ->notify(new NewUserAdminNotification($user));
    }
 
    return redirect()->route('dashboard');
}

Siete cosas, para ser precisos. Probablemente todos estarán de acuerdo en que es demasiado para un solo método del controlador, tenemos que separar la lógica y trasladar las partes a algún sitio. Pero, ¿dónde exactamente?

  • ¿Services?
  • ¿Jobs?
  • ¿Events/listeners?
  • ¿Actions?
  • ¿Algo más?

Lo más complicado es que todas las anteriores serían las respuestas correctas. Ese es probablemente el principal mensaje que debe llevarse a casa de este artículo. Lo subrayaré para ti, en negrita y en mayúsculas.

ES LIBRE DE ESTRUCTURAR SU PROYECTO COMO QUIERA.

Ya está, lo he dicho. En otras palabras, si ves alguna estructura recomendada en algún lugar, no significa que tengas que saltar y aplicarla en todas partes. La elección es siempre tuya. Tienes que elegir la estructura que sea cómoda para ti y para tu futuro equipo para mantener el código más adelante.

Con esto, probablemente podría incluso terminar el artículo ahora mismo. Pero probablemente quieras algo de "carne", ¿verdad? Ok, bien, vamos a jugar con el código de arriba.


Estrategia general de refactorización

En primer lugar, un descargo de responsabilidad, para que quede claro lo que estamos haciendo aquí, y por qué. Nuestro objetivo general es hacer el método del controlador más corto, para que no contenga ninguna lógica.

Los métodos del Controlador necesitan hacer tres cosas:

  • Aceptar los parámetros de las rutas u otras entradas
  • Llamar a algunas clases/métodos lógicos, y pasarle dichos parámetros
  • Devolver el resultado: vista, redirección, retorno JSON, etc.

Así, los controladores están llamando a los métodos, no implementando la lógica dentro del propio controlador.

Además, tenga en cuenta, que mis cambios sugeridos son sólo una manera de hacerlo, hay docenas de otras maneras que también funcionaría. Yo sólo le proporcionaré mis sugerencias, a partir de la experiencia personal.


1. Validaciones: Form Request

Es una preferencia personal, pero me gusta mantener las reglas de validación por separado, y Laravel tiene una gran solución para ello: Form Requests

Así que, generamos:

php artisan make:request StoreUserRequest

Movemos nuestras reglas de validación del controlador a esa clase. Además, tenemos que añadir la clase Password encima y cambiar el método authorize() para que devuelva true:

use Illuminate\Validation\Rules\Password;
 
class StoreUserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }
 
    public function rules()
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ];
    }
}

Finalmente, en nuestro método del Controlador, sustituimos Request $request por StoreUserRequest $request y eliminamos la lógica de validación del Controlador:

use App\Http\Requests\StoreUserRequest;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        // Aquí ya no se necesita $request->validate
 
        // Crear usuario
        $user = User::create([...]) // ...
    }
}

Bien, el primer acortamiento del controlador está hecho. Sigamos adelante.


2. Crear usuario: Services

A continuación, tenemos que crear un usuario y subir el avatar para él:

// Crear usuario
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    'password' => Hash::make($request->password),
]);
 
// Subir el avatar y actualizar el usuario
if ($request->hasFile('avatar')) {
    $avatar = $request->file('avatar')->store('avatars');
    $user->update(['avatar' => $avatar]);
}

Si seguimos las recomendaciones, esa lógica no debería estar en un Controlador. Los controladores no deberían saber nada sobre la estructura de la BD del usuario, o dónde almacenar los avatares. Sólo necesita llamar a algún método de la clase que se encargue de todo.

Un lugar bastante común para poner tal lógica es crear una Clase PHP separada alrededor de las operaciones de un Modelo. Se llama Services, pero eso es sólo un nombre oficial "elegante" para una clase PHP que "proporciona un servicio" para el Controlador.

Es por eso que no hay un comando como php artisan make:service porque es sólo una clase PHP, con cualquier estructura que desee, por lo que puede crearla manualmente dentro de su editor, en cualquier carpeta que desee.

Típicamente, los Services son creados cuando hay más de un método alrededor de la misma entidad o modelo. Así que, al crear un UserService aquí, asumimos que habrá más métodos aquí en el futuro, no sólo para crear el usuario.

Además, los Services suelen tener métodos que devuelven algo (así, "proporciona el servicio"). En comparación, los Actions o Jobs son llamados típicamente sin esperar nada de vuelta.

En mi caso, crearé la clase app/Services/UserService.php, con un método, por ahora.

namespace App\Services;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class UserService
{
    public function createUser(Request $request): User
    {
        // Crear usuario
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);
 
        // Subir el avatar y actualizar el usuario
        if ($request->hasFile('avatar')) {
            $avatar = $request->file('avatar')->store('avatars');
            $user->update(['avatar' => $avatar]);
        }
 
        return $user;
    }
}

Entonces, en el Controlador, podemos simplemente teclear esta clase de Servicio como un parámetro del método, y llamar al método dentro.

use App\Services\UserService;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
        $user = $userService->createUser($request);
 
        // Inicio de sesión y otras operaciones...

Sí, no necesitamos llamar a new UserService() en ningún sitio. Laravel te permite hacer una sugerencia de tipo a cualquier clase como esta en los controladores, puedes leer más sobre la inyección de métodos aquí en la documentación.

2.1. Services con principio de responsabilidad única

Ahora, el Controlador es mucho más corto, pero esta simple separación de código de copiar y pegar es un poco problemática.

El primer problema es que el método del Service debe actuar como una "caja negra" que sólo acepta los parámetros y no sabe de dónde vienen. Así que este método podría ser llamado desde un Controlador, desde el comando Artisan, o desde un Job, en el futuro.

Otro problema es que el método del Service viola el principio de Responsabilidad Única: crea el usuario y sube el archivo.

Por lo tanto, necesitamos dos "capas" más: una para la subida del archivo, y otra para la transformación desde el $request a los parámetros para la función. Y, como siempre, hay varias formas de implementarlo.

En mi caso, crearé un segundo método de servicio que subirá el archivo.

app/Services/UserService.php:

class UserService
{
    public function uploadAvatar(Request $request): ?string
    {
        return ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
    }
 
    public function createUser(array $userData): User
    {
        return User::create([
            'name' => $userData['name'],
            'email' => $userData['email'],
            'password' => Hash::make($userData['password']),
            'avatar' => $userData['avatar']
        ]);
    }
}

RegisteredUserController.php:

public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
 
    // ...

Nuevamente, repito: es solo una forma de separar las cosas, puedes hacerlo de otra manera.

Pero mi lógica es la siguiente:

El método createUser() ahora no sabe nada de la Solicitud, y podemos llamarlo desde cualquier comando de Artisan o desde otro lugar
La subida del avatar está separada de la operación de creación del usuario
Puedes pensar que los métodos del Service son demasiado pequeños para separarlos, pero este es un ejemplo muy simplificado: en proyectos de la vida real, el método de subida de archivos puede ser mucho más complejo, así como la lógica de creación de usuarios.

En este caso, nos alejamos un poco de la regla sagrada "haz un controlador más corto" y añadimos la segunda línea de código, pero por las razones correctas, en mi opinión.

3. ¿Action en lugar de Service?

En los últimos años, el concepto de Action se hizo popular en la comunidad de Laravel. La lógica es esta: tienes una clase separada para una sola acción. En nuestro caso, las clases action pueden ser:

  • CreateNewUser
  • UpdateUserPassword
  • UpdateUserProfile
  • etc.

Así que, como puedes ver, las mismas operaciones múltiples en torno a los usuarios, sólo que no en una clase UserService, sino divididas en clases Action. Puede tener sentido, mirando desde el punto de vista del Principio de Responsabilidad Única, pero me gusta agrupar los métodos en clases, en lugar de tener un montón de clases separadas. De nuevo, es una preferencia personal.

Ahora, echemos un vistazo a cómo nuestro código se vería en el caso de la clase Action.

Nuevamente, no hay php artisan make:action, solo crea una clase de PHP. Por ejemplo, voy a crear app/Actions/CreateNewUser.php:

namespace App\Actions;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class CreateNewUser
{
    public function handle(Request $request)
    {
        $avatar = ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
 
        return User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'avatar' => $avatar
        ]);
    }
}

Eres libre de elegir el nombre del método para la clase Action, me gusta handle().

Controlador de usuario registrado :

public function store(StoreUserRequest $request, CreateNewUser $createNewUser)
{
    $user = $createNewUser->handle($request);
 
    // ...

En otras palabras, descargamos TODA la lógica a la clase action que luego se encarga de todo lo relacionado con la carga de archivos y la creación de usuarios. Para ser honesto, ni siquiera estoy seguro de si es el mejor ejemplo para ilustrar las clases Action, ya que personalmente no soy un gran admirador de ellas y no las he usado mucho. Como otra fuente de ejemplos, puede echar un vistazo al código de Laravel Fortify .

4. Creación del voucher: ¿Usar el mismo Service o uno diferente?

A continuación, en el método del controlador, encontramos tres operaciones:

Auth::login($user);
 
$voucher = Voucher::create([
    'code' => Str::random(8),
    'discount_percent' => 10,
    'user_id' => $user->id
]);
 
$user->notify(new NewUserWelcomeNotification($voucher->code));

La operación de inicio de sesión permanecerá sin cambios aquí en el controlador, porque ya está llamando a una clase externa Auth, similar a un Service, y no necesitamos saber lo que está sucediendo bajo el capó allí.

Pero con el Voucher, en este caso, el Controlador contiene la lógica de cómo se debe crear el voucher y enviarlo al usuario con el email de bienvenida.

Primero, necesitamos mover la creación del voucher a una clase separada: estoy dudando entre crear un VoucherServicey ponerlo como un método dentro del mismo UserService. Eso es casi un debate filosófico: ¿qué tiene que ver este método con el sistema de vouchers, con el sistema de usuarios, o con ambos?

Como una de las características de los Service es contener múltiples métodos, decidí no crear un VoucherService "solitario" con un solo método. Lo haremos en el UserService:

use App\Models\Voucher;
use Illuminate\Support\Str;
 
class UserService
{
    // public function uploadAvatar() ...
    // public function createUser() ...
 
    public function createVoucherForUser(int $userId): string
    {
        $voucher = Voucher::create([
            'code' => Str::random(8),
            'discount_percent' => 10,
            'user_id' => $userId
        ]);
 
        return $voucher->code;
    }
}

Entonces, en el Controlador, lo llamamos así:

public function store(StoreUserRequest $request, UserService $userService)
{
	// ...
 
    Auth::login($user);
 
    $voucherCode = $userService->createVoucherForUser($user->id);
    $user->notify(new NewUserWelcomeNotification($voucherCode));

Otra cosa a tener en cuenta aquí: ¿quizás deberíamos mover ambas líneas a un método separado de UserService que fuera responsable del correo electrónico de bienvenida, que a su vez llamaría al método del voucher?

Algo así:

class UserService
{
    public function sendWelcomeEmail(User $user)
    {
        $voucherCode = $this->createVoucherForUser($user->id);
        $user->notify(new NewUserWelcomeNotification($voucherCode));
    }

Entonces, el controlador sólo tendrá una línea de código para esto:

$userService->sendWelcomeEmail($user);

5. Notificación a los administradores: Trabajos en cola

Finalmente, vemos este trozo de código en el Controlador:

foreach (config('app.admin_emails') as $adminEmail) {
    Notification::route('mail', $adminEmail)
        ->notify(new NewUserAdminNotification($user));
}

Está enviando potencialmente múltiples correos electrónicos, lo que puede llevar tiempo, así que necesitamos ponerlo en cola, para que se ejecute en segundo plano. Ahí es donde necesitamos Jobs.

Las clases de notificación de Laravel pueden ponerse en cola , pero para este ejemplo, imaginemos que puede haber algo más complejo que simplemente enviar un correo electrónico de notificación. Así que vamos a crear un trabajo para ello.

En este caso, Laravel nos proporciona el comando Artisan:

php artisan make:job NewUserNotifyAdminsJob

app/Jobs/NewUserNotifyAdminsJob.php:

class NewUserNotifyAdminsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    private User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
 
    public function handle()
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($this->user));
        }
    }
}

Luego, en el controlador, tenemos que llamar a ese trabajo con el parámetro:

use App\Jobs\NewUserNotifyAdminsJob;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
    	// ...
 
        NewUserNotifyAdminsJob::dispatch($user);

Así que, ahora, hemos movido toda la lógica del Controlador a otro lugar, y vamos a recapitular lo que tenemos:

public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
    $userService->sendWelcomeEmail($user);
    NewUserNotifyAdminsJob::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}

Más corto, separado en varios archivos, y todavía legible, ¿verdad? De nuevo, repetiré una vez más, que es sólo una forma de cumplir esta misión, puedes decidir estructurarlo de otra manera.

Pero eso no es todo. Vamos a hablar también de la forma "pasiva".


6. Events/Listeners

Podemos dividir todas las operaciones de este método del controlador en dos tipos: activas y pasivas.

  1. Estamos creando activamente el usuario e iniciando sesión
  2. Y luego algo con ese usuario puede (o no) suceder en segundo plano. Así que estamos esperando pasivamente esas otras operaciones: enviar un correo electrónico de bienvenida y notificar a los administradores.

Así que, como una forma de separar el código, no debe ser llamado en el Controlador en absoluto, sino que se dispara automáticamente cuando ocurre algún evento.

Puede usar una combinación de eventos y oyentes para ello:

php artisan make:event NewUserRegistered
php artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegistered
php artisan make:listener NewUserNotifyAdminsListener --event=NewUserRegistered

La clase de evento debe aceptar el modelo de usuario, que luego se pasa a CUALQUIER oyente de ese evento.

app/Events/NewUserRegistered.php

use App\Models\User;
 
class NewUserRegistered
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
 
    public User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
}

Entonces, el Evento es despachado desde el Controlador, así:

public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
 
    NewUserRegistered::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}

Y, en las clases de oyentes, repetimos la misma lógica:

use App\Events\NewUserRegistered;
use App\Services\UserService;
 
class NewUserWelcomeEmailListener
{
    public function handle(NewUserRegistered $event, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
    }
}

Y, otro más:

use App\Events\NewUserRegistered;
use App\Notifications\NewUserAdminNotification;
use Illuminate\Support\Facades\Notification;
 
class NewUserNotifyAdminsListener
{
    public function handle(NewUserRegistered $event)
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

¿Cuál es la ventaja de este enfoque, con eventos y oyentes? Se utilizan como "ganchos" en el código, y cualquier otra persona en el futuro podría utilizar ese gancho. En otras palabras, estás diciendo a los futuros desarrolladores: "Oye, el usuario está registrado, el evento ocurrió, y ahora si quieres añadir alguna otra operación que ocurra aquí, sólo tienes que crear tu oyente para ello".


7. Observers: eventos/oyentes "silenciosos"

En este caso, también se podría implementar un enfoque "pasivo" muy similar con un Observer .

app/Observadores/UserObserver.php :

use App\Models\User;
use App\Notifications\NewUserAdminNotification;
use App\Services\UserService;
use Illuminate\Support\Facades\Notification;
 
class UserObserver
{
    public function created(User $user, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
 
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

En ese caso, no necesitas enviar ningún evento en el Controlador, el Observador se dispararía inmediatamente después de la creación del modelo de Eloquent.

Conveniente, ¿verdad?

Pero, en mi opinión personal, este es un patrón un poco peligroso. No sólo la lógica de implementación está oculta al Controlador, sino que la mera existencia de esas operaciones no está clara. Imagina que un nuevo desarrollador se une al equipo dentro de un año, ¿comprobaría todos los posibles métodos del observador a la hora de mantener el registro del usuario?

Por supuesto, es posible averiguarlo, pero aún así, no es obvio. Y nuestro objetivo es hacer el código más mantenible, así que cuantas menos "sorpresas", mejor. Así que ten cuidado cuando uses un observer, sobre todo cuando trabajes en equipo.


Conclusión

Mirando ahora este artículo, me doy cuenta de que sólo he arañado la superficie de las posibles separaciones del código, en un ejemplo muy simple.

De hecho, en este sencillo ejemplo, puede parecer que hemos hecho la aplicación más compleja, creando muchas más clases PHP en lugar de una sola.

Pero, en este ejemplo, esas partes de código separadas son cortas. En la vida real, pueden ser mucho más complejas, y al separarlas, las hicimos más manejables, por lo que cada parte puede ser manejada por un desarrollador distinto, por ejemplo.

En general, lo repetiré por última vez: tú estás a cargo de tu aplicación, y sólo tú decides dónde colocas el código. El objetivo es que tú o tus compañeros de equipo lo entiendan en el futuro, y no tengan problemas para añadir nuevas características y mantener/arreglar las existentes.

Artículo traducido del blog de Laravel New


4 comentarios

Inicia sesión para comentar

Comentarios:

  • Alberto Guzman

    Alberto Guzman hace 1 año

    Muy buen post, dónde actualmente trabajo los desarrolladores anteriores eran Developers Siniors y su lógica de programación es muy similar a lo que hay aquí en este post, al principio resulta un poco difícil y hasta enredado pero poco a poco  vas entendiendo que es más fácil de  leer el proceso de una función de 10 línea  a una función de 100, eso solo por dar un ejemplo.

  • Alan Gilberto

    Alan Gilberto hace 1 año

    y yo que me creía todo un pro por usar form request…

     

  • Ger

    Ger hace 1 año

    excelente, cuando empece con laravel nunca aplique estos conceptos, me quede en lo ‘basico’, ahora estoy con livewire, estoy intentando aplicar lo de Actions y Services pero no hay caso, habra en el blog algun ejemplo?

    • Victor Arana Flores hace 1 año

      Hola a que te refieres con que no hay caso? Yo los aplico perfectamente tal como se explica en el articulo :o

    • Ger hace 1 año

      no recuerdo que error me daba, al final logre hacerlo importandolo como facade
      (use Facades\App\Services\ProductService)

  • Miguel Vázquez

    Miguel Vázquez hace 1 año

    Justamente hace un par de días entré en ese dilema de como separar la lógica de los controladores. Bastante útil esta info.

    Saludos!!