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:
- ¿Depende solo de los datos de una entidad? → Modelo.
- ¿Es una acción de negocio con varios pasos? → Servicio o clase de acción.
- ¿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.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp