Laravel

Arquitectura MVC en Laravel: dónde va cada cosa

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Casi todos los que empiezan con Laravel aprenden qué significa MVC en la primera semana. El problema aparece al tercer mes, cuando hay un controlador de seiscientas líneas y nadie sabe dónde poner lo siguiente.

Este artículo no es sobre qué significan las siglas. Es sobre dónde va cada cosa cuando el proyecto crece.

¡Empecemos!

El repaso rápido

MVC separa una aplicación en tres responsabilidades:

  • Modelo — los datos y las reglas que dependen de ellos.
  • Vista — lo que ve el usuario.
  • Controlador — recibe la petición, coordina y devuelve una respuesta.

La idea de fondo es que cada parte tenga una sola razón para cambiar. Si cambia el diseño, tocas vistas. Si cambia la estructura de datos, tocas modelos. Si cambia el flujo de una pantalla, tocas el controlador.

Cuando un cambio de diseño te obliga a tocar un modelo, algo está mal repartido.

El recorrido de una petición

En Laravel, el camino completo es este:

Navegador

routes/web.php          ¿qué controlador atiende esta URL?

Middleware              ¿está autenticado? ¿tiene permiso?

Form Request            ¿los datos son válidos?

Controlador             coordina

Modelo / Servicio       consulta o modifica los datos

Vista Blade             genera el HTML

Navegador

Fíjate en cuántas capas hay antes del controlador. Eso no es burocracia: cada una quita responsabilidades al controlador para que se quede solo con lo suyo.

El controlador debe ser aburrido

Esta es la regla más útil de todo el artículo.

Un controlador bien escrito se lee en diez segundos y no tiene ninguna decisión interesante.

public function store(GuardarCitaRequest $request, AgendarCita $agendar)
{
    $cita = $agendar->ejecutar($request->validated());

    return redirect()
        ->route('citas.show', $cita)
        ->with('exito', 'Cita agendada');
}

Tres líneas. Recibe, delega, responde. Si mañana hay que agendar citas desde una API o desde un comando, la lógica ya está en un sitio reutilizable.

Compáralo con lo que suele pasar:

public function store(Request $request)
{
    $datos = $request->validate([ /* veinte reglas */ ]);

    $doctor = Doctor::find($datos['doctor_id']);

    if (! $doctor->activo) {
        return back()->with('error', 'El doctor no está activo');
    }

    $ocupado = Cita::where('doctor_id', $doctor->id)
        ->where('fecha', $datos['fecha'])
        ->exists();

    if ($ocupado) {
        return back()->with('error', 'Ese horario está ocupado');
    }

    $cita = Cita::create($datos);

    if ($cita->paciente->esPrimeraVez()) {
        Mail::to($cita->paciente->correo)->send(new Bienvenida($cita->paciente));
    }

    Mail::to($cita->paciente->correo)->send(new CitaAgendada($cita));
    $doctor->notify(new NuevaCita($cita));
    Estadistica::incrementar('citas_mes');

    return redirect()->route('citas.show', $cita);
}

Funciona. Y es el principio del final: al sexto requisito nuevo, ese método tiene doscientas líneas y nadie se atreve a tocarlo.

Qué va en el modelo

El modelo es más que una tabla. Aquí va todo lo que depende de los datos de esa entidad:

class Cita extends Model
{
    protected $fillable = ['paciente_id', 'doctor_id', 'fecha', 'estado'];

    protected function casts(): array
    {
        return ['fecha' => 'datetime'];
    }

    // Relaciones
    public function paciente(): BelongsTo
    {
        return $this->belongsTo(Paciente::class);
    }

    // Scopes: consultas reutilizables
    public function scopePendientes($query)
    {
        return $query->where('estado', 'programada')
                     ->where('fecha', '>=', now());
    }

    // Datos derivados
    protected function estaVencida(): Attribute
    {
        return Attribute::get(fn () =>
            $this->fecha->isPast() && $this->estado === 'programada'
        );
    }

    // Reglas propias de la entidad
    public function sePuedeCancelar(): bool
    {
        return $this->estado === 'programada'
            && $this->fecha->diffInHours(now()) > 24;
    }
}

Ese sePuedeCancelar() es un buen ejemplo. Es una regla de negocio, pero depende solo de esta cita, así que su sitio es el modelo. Y ahora tanto el controlador como la vista como una API pueden preguntarlo igual:

@if ($cita->sePuedeCancelar())
    <button>Cancelar</button>
@endif

Lo que NO va en el modelo: enviar correos, generar PDF, hablar con APIs externas. Eso no es «datos de la cita».

Qué va en la vista

Solo mostrar. Y esa frontera se cruza más de lo que parece.

Aceptable en una vista:

@if ($cita->sePuedeCancelar())
@foreach ($citas as $cita)
{{ $cita->fecha->translatedFormat('d \d\e F') }}
@can('update', $cita)

No aceptable:

{{-- consultas desde la vista --}}
@foreach (Cita::where('estado', 'pendiente')->get() as $cita)

{{-- reglas de negocio en la plantilla --}}
@if ($cita->estado === 'programada' && $cita->fecha->diffInHours(now()) > 24
     && auth()->user()->rol === 'admin')

Esa segunda condición es la que hay que sacar al modelo. Si la lógica está en la vista, no se puede probar y se repite en cada plantilla donde haga falta.

Y una consulta dentro de un @foreach es la receta del problema N+1.

Dónde va lo demás

Aquí está el punto que MVC no resuelve por sí solo, y donde la gente se traba.

MVC tiene tres cajas. Una aplicación real tiene más cosas que tres.

¿Dónde va «agendar una cita», que valida disponibilidad, crea el registro, descuenta un cupo, manda dos correos y registra una estadística? No es un dato de la cita, no es presentación, y meterlo en el controlador es lo que produce el ejemplo de arriba.

Laravel te da capas adicionales para eso:

Responsabilidad Dónde va
Validar la entrada Form Request
Permisos Policy
Autenticación, idioma, registro Middleware
Una acción de negocio completa Clase de servicio o de acción
Reacciones secundarias Eventos y listeners
Trabajo pesado Colas
Formato de salida en una API API Resource
Consultas reutilizables Scopes del modelo

Así queda el ejemplo anterior, repartido:

class AgendarCita
{
    public function __construct(
        private VerificadorDisponibilidad $disponibilidad,
    ) {}

    public function ejecutar(array $datos): Cita
    {
        return DB::transaction(function () use ($datos) {
            $this->disponibilidad->comprobar($datos);

            $cita = Cita::create($datos);

            CitaAgendada::dispatch($cita);

            return $cita;
        });
    }
}

Los correos, la notificación al doctor y la estadística son tres listeners del evento CitaAgendada. Añadir un cuarto aviso mañana no toca este archivo.

La prueba de las tres preguntas

Cuando no sepas dónde poner algo, respóndete esto:

  1. ¿Depende solo de los datos de una entidad? → Modelo.
  2. ¿Es una acción de negocio con varios pasos? → Servicio o clase de acción.
  3. ¿Es una reacción secundaria a algo que pasó? → Evento y listener.

Y si ninguna encaja, probablemente sea coordinación, y entonces sí va en el controlador.

Señales de que está mal repartido

  • Un método de controlador que no cabe en la pantalla.
  • La misma lógica copiada en dos controladores.
  • Consultas Eloquent dentro de una vista Blade.
  • Un modelo que envía correos o llama a una API.
  • Un cambio de diseño que obliga a tocar un controlador.
  • No poder probar una regla de negocio sin simular una petición HTTP.

Cuándo no complicarse

Y ahora la advertencia, porque este artículo puede leerse como una invitación a crear cinco clases por pantalla.

No lo hagas. Un CRUD sencillo —listar, crear, editar, borrar— vive perfectamente en un controlador de recurso con Form Requests, y añadirle servicios solo lo hace más difícil de leer.

La regla práctica: empieza en el controlador y saca las cosas cuando duelan. Cuando un método pase de unas veinte líneas, cuando la misma lógica aparezca por segunda vez, o cuando necesites llamarla desde otro sitio. Antes de eso, la indirección no está pagando nada.

Es lo mismo que decimos sobre las interfaces en la lección de inyección de dependencias: la abstracción se añade cuando resuelve un problema real, no por adelantado.

Para cerrar

MVC no es una regla de tres cajas: es una forma de repartir responsabilidades para que cada cambio toque un solo lugar.

Si te quedas con una idea: el controlador debe ser aburrido. Si al leerlo te enteras de cómo funciona el negocio, hay lógica que debería estar en otro sitio.

Todo esto se ve aplicado en el curso de Laravel, especialmente en las lecciones de controladores y de inyección de dependencias.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp